Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ out/

### Claude Code ###
.claude/settings.local.json
.omc/
docs/superpowers/

### Serena ###
.serena/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.example.solidconnection.admin.auth.controller;

import com.example.solidconnection.admin.auth.dto.AdminReissueResponse;
import com.example.solidconnection.admin.auth.dto.AdminSignInRequest;
import com.example.solidconnection.admin.auth.dto.AdminSignInResponse;
import com.example.solidconnection.admin.auth.dto.AdminSignInResult;
import com.example.solidconnection.admin.auth.service.AdminAuthService;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin/auth")
@RequiredArgsConstructor
public class AdminAuthController {

private final AdminAuthService adminAuthService;
private final AdminRefreshTokenCookieManager adminRefreshTokenCookieManager;

@PostMapping("/sign-in")
public ResponseEntity<AdminSignInResponse> signIn(
@RequestBody @Valid AdminSignInRequest request,
HttpServletResponse response
) {
AdminSignInResult result = adminAuthService.signIn(request);
adminRefreshTokenCookieManager.setCookie(response, result.adminRefreshToken());
return ResponseEntity.ok(AdminSignInResponse.from(result.accessToken()));
}

@PostMapping("/reissue")
public ResponseEntity<AdminReissueResponse> reissue(HttpServletRequest request) {
String adminRefreshToken = adminRefreshTokenCookieManager.getAdminRefreshToken(request);
AdminReissueResponse reissueResponse = adminAuthService.reissue(adminRefreshToken);
return ResponseEntity.ok(reissueResponse);
}

@PostMapping("/sign-out")
public ResponseEntity<Void> signOut(
Authentication authentication,
HttpServletResponse response
) {
String accessToken = getAccessToken(authentication);
adminAuthService.signOut(accessToken);
adminRefreshTokenCookieManager.deleteCookie(response);
return ResponseEntity.ok().build();
}

private String getAccessToken(Authentication authentication) {
if (authentication == null || !(authentication.getCredentials() instanceof String accessToken)) {
throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "엑세스 토큰이 없습니다.");
}
return accessToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.example.solidconnection.admin.auth.controller;

import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_NOT_EXISTS;

import com.example.solidconnection.admin.auth.controller.config.AdminRefreshTokenCookieProperties;
import com.example.solidconnection.auth.token.config.TokenProperties;
import com.example.solidconnection.common.exception.CustomException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Arrays;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AdminRefreshTokenCookieManager {

private static final String PATH = "/";

private final AdminRefreshTokenCookieProperties properties;
private final TokenProperties tokenProperties;

public void setCookie(HttpServletResponse response, String adminRefreshToken) {
Duration tokenExpireTime = tokenProperties.adminRefresh().expireTime();
long cookieMaxAge = tokenExpireTime.toSeconds();
setAdminRefreshTokenCookie(response, adminRefreshToken, cookieMaxAge);
}

public void deleteCookie(HttpServletResponse response) {
setAdminRefreshTokenCookie(response, "", 0);
}

private void setAdminRefreshTokenCookie(
HttpServletResponse response, String adminRefreshToken, long maxAge
) {
ResponseCookie cookie = ResponseCookie.from(properties.cookieName(), adminRefreshToken)
.httpOnly(true)
.secure(true)
.path(PATH)
.maxAge(maxAge)
.domain(properties.cookieDomain())
.sameSite(SameSite.STRICT.attributeValue())
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}

public String getAdminRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
throw new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS);
}

Cookie adminRefreshTokenCookie = Arrays.stream(cookies)
.filter(cookie -> properties.cookieName().equals(cookie.getName()))
.findFirst()
.orElseThrow(() -> new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS));

String adminRefreshToken = adminRefreshTokenCookie.getValue();
if (adminRefreshToken == null || adminRefreshToken.isBlank()) {
throw new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS);
}
return adminRefreshToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.admin.auth.controller.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "token.admin-refresh")
public record AdminRefreshTokenCookieProperties(
String cookieName,
String cookieDomain
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.admin.auth.dto;

import com.example.solidconnection.auth.domain.AccessToken;

public record AdminReissueResponse(
String accessToken
) {

public static AdminReissueResponse from(AccessToken accessToken) {
return new AdminReissueResponse(accessToken.token());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.admin.auth.dto;

import jakarta.validation.constraints.NotBlank;

public record AdminSignInRequest(
@NotBlank String email,
@NotBlank String password
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.admin.auth.dto;

public record AdminSignInResponse(
String accessToken
) {

public static AdminSignInResponse from(String accessToken) {
return new AdminSignInResponse(accessToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.solidconnection.admin.auth.dto;

import com.example.solidconnection.auth.domain.AccessToken;
import com.example.solidconnection.auth.domain.AdminRefreshToken;

public record AdminSignInResult(
String accessToken,
String adminRefreshToken
) {

public static AdminSignInResult of(
AccessToken accessToken,
AdminRefreshToken adminRefreshToken
) {
return new AdminSignInResult(accessToken.token(), adminRefreshToken.token());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.example.solidconnection.admin.auth.service;

import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED;
import static com.example.solidconnection.common.exception.ErrorCode.NOT_ADMIN_USER;
import static com.example.solidconnection.common.exception.ErrorCode.SIGN_IN_FAILED;

import com.example.solidconnection.admin.auth.dto.AdminReissueResponse;
import com.example.solidconnection.admin.auth.dto.AdminSignInRequest;
import com.example.solidconnection.admin.auth.dto.AdminSignInResult;
import com.example.solidconnection.auth.domain.AccessToken;
import com.example.solidconnection.auth.domain.AdminRefreshToken;
import com.example.solidconnection.auth.exception.AuthException;
import com.example.solidconnection.auth.service.AuthTokenProvider;
import com.example.solidconnection.auth.token.TokenBlackListService;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.siteuser.domain.AuthType;
import com.example.solidconnection.siteuser.domain.Role;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AdminAuthService {

private final AuthTokenProvider authTokenProvider;
private final TokenBlackListService tokenBlackListService;
private final SiteUserRepository siteUserRepository;
private final PasswordEncoder passwordEncoder;

@Transactional
public AdminSignInResult signIn(AdminSignInRequest request) {
SiteUser siteUser = getEmailMatchingUserOrThrow(request.email());
validatePassword(request.password(), siteUser.getPassword());
validateAdminRole(siteUser);
resetQuitedAt(siteUser);
AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser);
AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(siteUser);
return AdminSignInResult.of(accessToken, adminRefreshToken);
}

private SiteUser getEmailMatchingUserOrThrow(String email) {
return siteUserRepository.findByEmailAndAuthType(email, AuthType.EMAIL)
.orElseThrow(() -> new CustomException(SIGN_IN_FAILED));
}

private void validatePassword(String rawPassword, String encodedPassword) {
if (!passwordEncoder.matches(rawPassword, encodedPassword)) {
throw new CustomException(SIGN_IN_FAILED);
}
}

private void validateAdminRole(SiteUser siteUser) {
if (!Role.ADMIN.equals(siteUser.getRole())) {
throw new CustomException(NOT_ADMIN_USER);
}
}

private void resetQuitedAt(SiteUser siteUser) {
if (siteUser.getQuitedAt() == null) {
return;
}
siteUser.setQuitedAt(null);
}

public AdminReissueResponse reissue(String requestedAdminRefreshToken) {
if (!authTokenProvider.isValidAdminRefreshToken(requestedAdminRefreshToken)) {
throw new AuthException(ADMIN_REFRESH_TOKEN_EXPIRED);
}
SiteUser siteUser = authTokenProvider.parseSiteUser(requestedAdminRefreshToken);
AccessToken newAccessToken = authTokenProvider.generateAccessToken(siteUser);
return AdminReissueResponse.from(newAccessToken);
}

public void signOut(String accessToken) {
tokenBlackListService.addToBlacklist(accessToken);
authTokenProvider.deleteAdminRefreshTokenByAccessToken(accessToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public record ApplicantsResponse(
Integer studentCapacity,
String region,
String country,
String logoImageUrl,
String backgroundImageUrl,
List<ApplicantResponse> applicants) {

public static ApplicantsResponse of(UnivApplyInfo univApplyInfo, List<Application> applications, SiteUser siteUser) {
Expand All @@ -19,6 +21,8 @@ public static ApplicantsResponse of(UnivApplyInfo univApplyInfo, List<Applicatio
univApplyInfo.getStudentCapacity(),
univApplyInfo.getUniversity().getRegion().getKoreanName(),
univApplyInfo.getUniversity().getCountry().getKoreanName(),
univApplyInfo.getUniversity().getLogoImageUrl(),
univApplyInfo.getUniversity().getBackgroundImageUrl(),
applications.stream()
.map(application -> ApplicantResponse.of(application, isUsers(application, siteUser)))
.toList());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ private void setRefreshTokenCookie(
.path(PATH)
.maxAge(maxAge)
.domain(properties.cookieDomain())
.sameSite(SameSite.LAX.attributeValue())
.sameSite(SameSite.STRICT.attributeValue())
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.solidconnection.auth.domain;

public record AdminRefreshToken(
String token
) implements Token {

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;

import com.example.solidconnection.auth.domain.AccessToken;
import com.example.solidconnection.auth.domain.AdminRefreshToken;
import com.example.solidconnection.auth.domain.RefreshToken;
import com.example.solidconnection.auth.domain.Subject;
import com.example.solidconnection.auth.token.config.TokenProperties;
Expand Down Expand Up @@ -54,6 +55,16 @@ public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) {
return tokenStorage.saveToken(subject, refreshToken);
}

public AdminRefreshToken generateAndSaveAdminRefreshToken(SiteUser siteUser) {
Subject subject = toSubject(siteUser);
String token = tokenProvider.generateToken(
subject,
tokenProperties.adminRefresh().expireTime()
);
AdminRefreshToken adminRefreshToken = new AdminRefreshToken(token);
return tokenStorage.saveToken(subject, adminRefreshToken);
}

/*
* 유효한 리프레시 토큰인지 확인한다.
* - 요청된 토큰과 같은 subject 의 리프레시 토큰을 조회한다.
Expand All @@ -66,11 +77,23 @@ public boolean isValidRefreshToken(String requestedRefreshToken) {
.orElse(false);
}

public boolean isValidAdminRefreshToken(String requestedAdminRefreshToken) {
Subject subject = tokenProvider.parseSubject(requestedAdminRefreshToken);
return tokenStorage.findToken(subject, AdminRefreshToken.class)
.map(foundToken -> Objects.equals(foundToken, requestedAdminRefreshToken))
.orElse(false);
}

public void deleteRefreshTokenByAccessToken(String accessToken) {
Subject subject = tokenProvider.parseSubject(accessToken);
tokenStorage.deleteToken(subject, RefreshToken.class);
}

public void deleteAdminRefreshTokenByAccessToken(String accessToken) {
Subject subject = tokenProvider.parseSubject(accessToken);
tokenStorage.deleteToken(subject, AdminRefreshToken.class);
}

public SiteUser parseSiteUser(String token) {
Subject subject = tokenProvider.parseSubject(token);
long siteUserId = Long.parseLong(subject.value());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.solidconnection.auth.token.config;

import com.example.solidconnection.auth.domain.AccessToken;
import com.example.solidconnection.auth.domain.AdminRefreshToken;
import com.example.solidconnection.auth.domain.RefreshToken;
import com.example.solidconnection.auth.domain.SignUpToken;
import com.example.solidconnection.auth.domain.Token;
Expand All @@ -13,6 +14,7 @@
public record TokenProperties(
TokenConfig access,
TokenConfig refresh,
TokenConfig adminRefresh,
TokenConfig signUp,
TokenConfig blackList
) {
Expand All @@ -32,6 +34,7 @@ public void init() {
tokenConfigs = Map.of(
AccessToken.class, access,
RefreshToken.class, refresh,
AdminRefreshToken.class, adminRefresh,
SignUpToken.class, signUp
);
}
Expand Down
Loading
Loading