-
Notifications
You must be signed in to change notification settings - Fork 8
feat: 웹, 어드민 간 refresh token 분리 #732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
1c53aaa
c9ed44f
a08cef1
8db2d89
644dd3e
bf17d49
563c0f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.LAX.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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the admin refresh JWT is actually expired, Useful? React with 👍 / 👎. |
||
| 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 |
|---|---|---|
| @@ -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 | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||
|
|
@@ -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 의 리프레시 토큰을 조회한다. | ||||||||||||||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+80
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1) Line 81에서 파싱 예외가 전파되면, 호출부가 기대한 “유효성 실패(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);
+ try {
+ Subject subject = tokenProvider.parseSubject(requestedAdminRefreshToken);
+ return tokenStorage.findToken(subject, AdminRefreshToken.class)
+ .map(foundToken -> Objects.equals(foundToken, requestedAdminRefreshToken))
+ .orElse(false);
+ } catch (RuntimeException e) {
+ return false;
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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()); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
기존 LAX 방식이었던 이유가 어떤 거였는지 알려주실 수 있나요?? 인증 정보가 관리지 웹(www.admins.solid-connection.com)과 서비스 웹(www.solid-connection.com)으로 완전 분리되었으니까 Strict를 해도 되지 않을까라는 생각이 들어 의견을 듣고 싶습니다!