| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Packet Network
- Spring Boot
- java
- SDK upgrade
- linux
- Git
- OverTheWire
- docker
- MySQL
- express
- critical rendering path
- Unity IAP
- draganddrop
- nodejs
- rpg server
- Google Developer API
- --watch
- screencapture
- mongoDB
- spread 연산자
- react
- Google Refund
- Camera Zoom
- Unity Editor
- Camera Movement
- Digital Ocean
- unity
- springboot
- server
- css framework
- Today
- Total
우당탕탕 개발일지
[방치RPG 서버 제작기] 5. 액세스 토큰 + 리프레시 토큰 관리 및 갱신 본문
1. Jwt 인증을 위한 스크립트 3개 정리 (서버)
1) JwtProvider
JWT 토큰 생성, 검증, 사용자 정보 추출을 담당한다.
- createToken(userId) : accessToken 생성
- createRefreshToken(userId) : refreshToken 생성 (주의: subject에 userId 꼭 넣기!)
- isTokenValid(token) : 만료 여부 확인
- getUserIdFromToken(token) : 토큰에서 userId 추출
package com.esaAdventure.dante_server.common.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
@Component
public class JwtProvider {
@Value("${jwt.secret}")
private String secretKey;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String createToken(Long userId) {
Date now = new Date();
// 2시간
long EXPIRATION_TIME = 1000L * 60 * 60 * 2;
Date expiry = new Date(now.getTime() + EXPIRATION_TIME);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
public boolean isTokenValid(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public UsernamePasswordAuthenticationToken getAuthentication(String token) {
Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
String userId = claims.getSubject(); // 또는 deviceId 등
UserDetails userDetails = new org.springframework.security.core.userdetails.User(userId, "", List.of());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String createRefreshToken(Long userId) {
long refreshExp = 1000L * 60 * 60 * 24 * 14; // 14일짜리
Date now = new Date();
Date expiry = new Date(now.getTime() + refreshExp);
return Jwts.builder()
.setSubject(String.valueOf((userId)))
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
}
2) SecurityConfig
Spring Security 설정 클래스.
- .requestMatchers("/api/user/auth/**").permitAll() 로 로그인/회원가입 API 허용
- .anyRequest().authenticated() 로 나머지는 인증 필요
- .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
package com.esaAdventure.dante_server.config;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtProvider jwtProvider;
public SecurityConfig(JwtProvider jwtProvider) {
this.jwtProvider = jwtProvider;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/user/auth/guest").permitAll() // 로그인/회원가입은 허용
.requestMatchers("/api/user/auth/**").permitAll()
.anyRequest().authenticated() // 나머지는 인증 필요
)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
3) JwtAuthenticationFilter
요청마다 JWT accessToken 검증하는 필터
- Authorization 헤더에서 Bearer 토큰 추출
- 검증 성공 시 SecurityContext 에 인증 정보 저장
package com.esaAdventure.dante_server.common.security;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
public JwtAuthenticationFilter(JwtProvider jwtProvider) {
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtProvider.isTokenValid(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
2. [Error] permitAll 설정했는데도 UnAuth 뜨는 문제
@EnableWebSecurity
public class SecurityConfig {
로그인 API에서 permitAll() 설정을 해놨는데도 UnAuth 가 리턴되며 로그인 되지 않는 현상이 발생했다. 원인은 SecurityConfig에서 @Configuration 어노테이션을 붙이지 않아서 필터 자체가 등록이 안되었던 것이었다.Spring이 Security 설정 클래스를 인식하지 못해서 아무리 permitAll()을 해도 무시된다.
해결법:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
이렇게 @Configuration 꼭 붙여줘야 한다.
이제 서버에서 액세스토큰, 리프레시토큰을 생성및 검증하는 코드는완료되었다. 이제 로그인 시 액세스토큰과 리프레시를 같이 반환하고, 액세스토큰이 만료되었을 경우 갱신하는 방법을 알아보자!
3. AccessToken과 RefreshToken의 개념
- Access Token
- 유저 인증용, 짧은 수명 (보통 15분 ~ 2시간)
- 모든 API 요청에 사용됨
- Refresh Token
- accessToken이 만료됐을 때 새로 발급받기 위한 인증 수단
- 보통 7일 ~ 30일 유지
토큰은 클라이언트가 보관하고, 서버는 refreshToken만 DB나 Redis 등에 저장해서 관리한다.
4. 토큰 갱신을 위한 AuthController, TokenService 생성하기
AuthController.java
package com.esaAdventure.dante_server.controller.auth;
@RestController
@RequestMapping("/api/user/auth")
public class AuthController {
private final JwtProvider jwtProvider;
private final TokenService tokenService;
public AuthController(JwtProvider jwtProvider, TokenService tokenService) {
this.jwtProvider = jwtProvider;
this.tokenService = tokenService;
}
@PostMapping("/refresh")
public ResponseEntity<?> refreshAccessToken(HttpServletRequest request) {
String refreshToken = resolveToken(request);
if (refreshToken == null) {
return new ResponseEntity<>("토큰 없음", HttpStatus.UNAUTHORIZED);
}
if (!jwtProvider.isTokenValid(refreshToken)) {
return new ResponseEntity<>("토큰 만료됨", HttpStatus.UNAUTHORIZED);
}
Long userId = jwtProvider.getUserIdFromToken(refreshToken);
// ✅ 저장된 refreshToken과 비교
if (!tokenService.isRefreshTokenValid(userId, refreshToken)) {
return new ResponseEntity<>("리프레시 토큰 불일치", HttpStatus.UNAUTHORIZED);
}
String newAccessToken = jwtProvider.createToken(userId);
return new ResponseEntity<>(new AccessTokenResponse(newAccessToken), HttpStatus.OK);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
TokenService.java
package com.esaAdventure.dante_server.service.auth;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class TokenService {
private final Map<Long, String> refreshTokenStorage = new ConcurrentHashMap<>();
public void saveRefreshToken(Long userId, String token) {
refreshTokenStorage.put(userId, token);
}
public boolean isRefreshTokenValid(Long userId, String token) {
return token.equals(refreshTokenStorage.get(userId));
}
}
TokenResponse.java (dto)
package com.esaAdventure.dante_server.dto.auth;
public record TokenResponse(String accessToken, String refreshToken) {}
AccessTokenRefresh.java. (dto)
package com.esaAdventure.dante_server.dto.auth;
public record AccessTokenResponse(String accessToken) {}
5. 로그인 시 두 토큰을 함께 반환하기(UserController.java)
@PostMapping("/auth/guest")
public ResponseEntity<UserResponse> guestLogin(@RequestBody GuestLoginRequest request) {
User user = userService.guestLogin(request.getDeviceId());
HttpStatus status = user.isNewlyCreated() ? HttpStatus.CREATED : HttpStatus.OK;
Long userId = user.getId();
if (user.isNewlyCreated()) {// 신규 유저라면 기본 데이터 생성
userDataService.createInitialData(userId);
}
String accessToken = jwtProvider.createToken(userId);
String refreshToken = jwtProvider.createRefreshToken(userId);
String gameDataJson = userDataService.getAllUserDataAsJson(user.getId());
// 📦 서버 DB에 refreshToken 저장 (예: 유저ID별로)
tokenService.saveRefreshToken(userId, refreshToken);
//return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
// UserResponse에 통합해서 응답
UserResponse response = new UserResponse(accessToken,refreshToken, gameDataJson);
return new ResponseEntity<>(response, status);
}
UserResponse에 accessToken, refreshToken, userData 모두 포함시켜서 한번에 전달한다.
6. 유니티에서 액세스토큰 관리 구조 설계 (클라이언트)
- BackendManager : 실제 통신 담당, accessToken 자동 붙이기 + 만료 시 자동 갱신
- TokenHandler : refreshToken으로 accessToken 갱신 처리
- LocalTokenStore : accessToken / refreshToken 저장, 불러오기
모든 요청은 BackendManager.SendRequest<T>() 로 통일해서 사용하면 유지보수 편함.
accessToken 만료되면 → refresh → 다시 원래 요청 재시도 → 실패 시 로그인 유도까지 자동화.
BackendManager.java
public static class BackendManager
{
private const string BASE_URL = "http://localhost:8080";
public static async Task<T> SendRequest<T>(string path, string method, object body = null)
{
string url = $"{BASE_URL}{path}";
string accessToken = LocalTokenStore.AccessToken;
var request = UnityWebRequest.Put(url, "");
request.method = method;
request.SetRequestHeader("Content-Type", "application/json");
if (!string.IsNullOrEmpty(accessToken))
request.SetRequestHeader("Authorization", $"Bearer {accessToken}");
if (body != null)
{
string json = JsonUtility.ToJson(body);
byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
request.uploadHandler = new UploadHandlerRaw(jsonBytes);
}
request.downloadHandler = new DownloadHandlerBuffer();
await request.SendWebRequest();
// 만료된 accessToken일 경우 -> refresh 시도
if (request.responseCode == 401)
{
bool refreshSuccess = await TokenHandler.TryRefreshToken();
if (refreshSuccess)
{
// 재시도
return await SendRequest<T>(path, method, body);
}
else
{
// 로그인 화면으로 유도
UIManager.ShowLoginScreen();
throw new Exception("AccessToken 만료 및 refresh 실패");
}
}
// 정상 응답 처리
if (request.result == UnityWebRequest.Result.Success)
{
return JsonUtility.FromJson<T>(request.downloadHandler.text);
}
else
{
Debug.LogError("API 요청 실패: " + request.error);
throw new Exception(request.error);
}
}
}
TokenHandler.java
public static class TokenHandler
{
public static async Task<bool> TryRefreshToken()
{
string refreshToken = LocalTokenStore.RefreshToken;
if (string.IsNullOrEmpty(refreshToken)) return false;
var request = new UnityWebRequest("http://localhost:8080/api/user/auth/refresh", "POST");
request.SetRequestHeader("Authorization", $"Bearer {refreshToken}");
request.downloadHandler = new DownloadHandlerBuffer();
await request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var tokenResponse = JsonUtility.FromJson<AccessTokenResponse>(request.downloadHandler.text);
LocalTokenStore.AccessToken = tokenResponse.accessToken;
return true;
}
return false;
}
[Serializable]
public class AccessTokenResponse
{
public string accessToken;
}
}
LocalTokenStore.java
public static class LocalTokenStore
{
private const string ACCESS_KEY = "access_token";
private const string REFRESH_KEY = "refresh_token";
public static string AccessToken
{
get => PlayerPrefs.GetString(ACCESS_KEY, null);
set => PlayerPrefs.SetString(ACCESS_KEY, value);
}
public static string RefreshToken
{
get => PlayerPrefs.GetString(REFRESH_KEY, null);
set => PlayerPrefs.SetString(REFRESH_KEY, value);
}
public static void Clear()
{
PlayerPrefs.DeleteKey(ACCESS_KEY);
PlayerPrefs.DeleteKey(REFRESH_KEY);
}
}
사용할땐 다음과 같이 사용하면 된다.
await ApiManager.SendRequest<object>("/api/backup", "POST", new { data = myData });
7. Unity의 BackendManager 분리 설계
API 많아지면 하나의 클래스에서 관리하기엔 헬이 되므로, 기능별로 분리하는것이 좋다.
권장 구조:
- BackendManager : 통신 + 토큰 갱신 공통 처리
- UserService, InventoryService, GachaService 등 → API 의미 단위별로 나눔
이렇게 분리하면:
- 각 API 역할이 명확해지고
- 테스트도 쉬워지고
- 추후 리팩토링도 간편해진다
'Server > 방치RPG 서버' 카테고리의 다른 글
| [방치RPG 서버 제작기] 7. backup API 구현하기 (0) | 2025.04.26 |
|---|---|
| [방치RPG 서버 제작기] 6. 전송 암호화, DB테이블 구조 분리하기 (0) | 2025.04.25 |
| [방치RPG 서버 제작기] 4. 액세스 토큰 발급 , 기본 데이터 저장하기 (0) | 2025.04.22 |
| [방치RPG 서버 제작기] 3. 방치 RPG 유저 데이터 관리 (이론) (0) | 2025.04.21 |
| [방치RPG 서버 제작기] 2. 게스트 로그인 및 엑세스 토큰 발급 (2) | 2025.02.02 |