Skip to content
Open
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
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())
Copy link
Copy Markdown
Contributor

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를 해도 되지 않을까라는 생각이 들어 의견을 듣고 싶습니다!

.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)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear expired admin refresh cookies

When the admin refresh JWT is actually expired, isValidAdminRefreshToken() calls tokenProvider.parseSubject() before it can return false; JwtTokenProvider catches ExpiredJwtException and raises CustomException(INVALID_TOKEN), so the ADMIN_REFRESH_TOKEN_EXPIRED branch in CustomExceptionHandler is skipped and the admin refresh cookie is not deleted. In that expired-cookie scenario /admin/auth/reissue keeps returning an invalid-token error while leaving the stale cookie in the browser, defeating the new admin-cookie cleanup path.

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
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);
}
Comment on lines +80 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

1) isValidAdminRefreshToken은 파싱 실패를 false로 정규화해 주세요.

Line 81에서 파싱 예외가 전파되면, 호출부가 기대한 “유효성 실패(false)” 흐름이 깨질 수 있습니다. 이 메서드는 비정상 입력에 대해 일관되게 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 boolean isValidAdminRefreshToken(String requestedAdminRefreshToken) {
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;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java`
around lines 80 - 85, Wrap the call to tokenProvider.parseSubject in
isValidAdminRefreshToken with a try-catch that catches parsing exceptions (e.g.,
IllegalArgumentException or the specific exception thrown by parseSubject) and
returns false on error; if parsing succeeds, proceed to use
tokenStorage.findToken(subject, AdminRefreshToken.class).map(...).orElse(false)
as before so any parse failure is normalized to false without propagating
exceptions.


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
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.example.solidconnection.common.exception;

import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED;
import static com.example.solidconnection.common.exception.ErrorCode.DATA_INTEGRITY_VIOLATION;
import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT;
import static com.example.solidconnection.common.exception.ErrorCode.JSON_PARSING_FAILED;
import static com.example.solidconnection.common.exception.ErrorCode.JWT_EXCEPTION;
import static com.example.solidconnection.common.exception.ErrorCode.NOT_DEFINED_ERROR;
import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED;

import com.example.solidconnection.admin.auth.controller.AdminRefreshTokenCookieManager;
import com.example.solidconnection.auth.controller.RefreshTokenCookieManager;
import com.example.solidconnection.auth.exception.AuthException;
import com.example.solidconnection.common.response.ErrorResponse;
Expand All @@ -30,6 +32,7 @@
public class CustomExceptionHandler {

private final RefreshTokenCookieManager refreshTokenCookieManager;
private final AdminRefreshTokenCookieManager adminRefreshTokenCookieManager;

@ExceptionHandler(AuthException.class)
protected ResponseEntity<ErrorResponse> handleAuthException(
Expand All @@ -40,6 +43,9 @@ protected ResponseEntity<ErrorResponse> handleAuthException(
if (ex.getErrorCode().equals(REFRESH_TOKEN_EXPIRED)) {
refreshTokenCookieManager.deleteCookie(response);
}
if (ex.getErrorCode().equals(ADMIN_REFRESH_TOKEN_EXPIRED)) {
adminRefreshTokenCookieManager.deleteCookie(response);
}
ErrorResponse errorResponse = new ErrorResponse(ex);
return ResponseEntity
.status(ex.getCode())
Expand Down
Loading
Loading