일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- css framework
- Unity Editor
- unity
- nodejs
- server
- linux
- Camera Zoom
- critical rendering path
- Packet Network
- rpg server
- springboot
- react
- Digital Ocean
- Google Developer API
- --watch
- express
- Camera Movement
- Google Refund
- Git
- mongoDB
- Unity IAP
- Spring Boot
- screencapture
- draganddrop
- spread 연산자
- docker
- OverTheWire
- MySQL
- java
- SDK upgrade
- Today
- Total
우당탕탕 개발일지
[방치RPG 서버 제작기] 2. 게스트 로그인 및 엑세스 토큰 발급 본문
오늘의 목표
- 게스트 가입과 게스트 로그인을 구현한다.
- 게스트 가입/로그인 시 성공한 유저의id, 백업된 데이터(로그인 유저일 경우) , 엑세스토큰을 반환한다.
가장 외부부터 보자. AuthController.js에서는 guest로 가입하는 함수, uid를 이용해 로그인하는 함수 2개를 만들었다. 실제 가입/로그인 로직은 UserService.java에서 이루어지고있다.
//AuthController.java
@PostMapping("/register/guest")
public ResponseEntity<?> registerGuest() {
User newGuest = userService.registerGuest(); 유저가입
//가입정보를 바탕으로 로그인 진행
Optional<LoginResponse> loginResponse = userService.login(newGuest.getUid());
return loginResponse.isPresent()
? ResponseEntity.ok(loginResponse.get())
: ResponseEntity.status(500).body("Registration failed");
}
// 로그인 및 백업 조회 API
@GetMapping("/login/{uid}")
public ResponseEntity<?> login(@PathVariable String uid) {
Optional<LoginResponse> loginResponse = userService.login(uid);
return loginResponse.isPresent()
? ResponseEntity.ok(loginResponse.get())
: ResponseEntity.status(404).body("Guest ID not found");
}
//UserService.java
//register : guest
public User registerGuest() {
User newGuest = new User();
newGuest.setUid("GUEST_" + System.currentTimeMillis());
newGuest.setUsername("Guest_" + newGuest.getUid());
return userRepository.save(newGuest);
}
//login
public Optional<LoginResponse> login(String uid) {
Optional<User> userOptional = getUserByUid(uid);
return userOptional.map(user -> {
String accessToken = jwtUtil.generateToken(user.getUid());
return new LoginResponse(user, user.getBackup(), accessToken);
});
}
registerGuest() 에서는 새로운 유저를 생성하고, uid와 userName 을 설정한 뒤 userRepository 에 저장한다.
로그인의 경우 accessToken을 발급받는다. 토큰은 jwtUtil에서 uid를 이용해 생성할 수 있다.
엑세스 토큰을 발급하기 위해서는 의존성을 몇개 추가해야 한다.
//build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5' //jwt 생성 및 파싱
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' //구현체
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' //json처리를 위한 모듈
}
총 4개의 파일이 추가로 피룡하다. security 폴더 내의 JwtAuthenticationFilter.java , JwtUtil.java, SecurityConfig.java
config 폴더에 있는 WebConfig.java이다.
WebConfig.java는 통신할때 특정 도메인에서만 받도록 설정하거나, 헤더 설정, 메소드설정 등을 할 수 있다. 홈페이지가 있다면 해당 홈페이지에서만 통신할 수 있도록 막겠지만, 나의 경우 Unity로 게임을 만들 에정이므로 모든 출처를 허용하였다.
//WebConfig.java
package com.esa.rpg_server.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*") //모든 출처 허용
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true); // 쿠키 및 인증 정보 허용
}
}
JwtUtil.java 에서는 액세스토큰을 발급해주는 역할을 한다. userId를 이용해 generateToken()을 할 수 있다.
package com.esa.rpg_server.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.security.Key;
@Component
public class JwtUtil {
private final String SECRET_KEY = "SuperSecretKeyForJWTThatIsVerySecure12345"; // 최소 32바이트 이상 추천
private final long EXPIRATION_TIME = 1000 * 60 * 60; // 1시간 (ms)
// 🔑 Key 객체로 변환 (HMAC-SHA256)
private final Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
//1. JWT 토큰 생성
public String generateToken(String userId) {
return Jwts.builder()
.setSubject(userId) // 토큰의 주체 (userId)
.setIssuedAt(new Date()) // 토큰 발급 시간
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시간
.signWith(key, SignatureAlgorithm.HS256) // HMAC-SHA256으로 서명
.compact();
}
//2. JWT 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token); // 파싱하며 유효성 검사 수행
return true;
} catch (JwtException | IllegalArgumentException e) {
// 만료된 토큰, 잘못된 서명 등 예외 처리
System.out.println("Invalid JWT Token: " + e.getMessage());
return false;
}
}
// 3. JWT 토큰에서 사용자 ID 추출
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}
로그인, 가입시에는 토큰을 발급하는것이고, 로그인 후에 필요한 통신들(뽑기, 내정보보기 등)은 통신 전 액세스토큰을 검증하여 올바르지 않을 경우 fail을 반환해야 한다. JwtAuthenticationFilter.java 에서는 어떤부분에서 액세스토큰 검사를 진행할지를 지정한다.
pathMatcher.match() 함수를 통해서 로그인이나 가입 통신에서는 액세스토큰 검사를 건너뛰도록 하고있다.
package com.esa.rpg_server.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (requestURI.startsWith("/api/auth/register") ||
requestURI.startsWith("/api/auth/login")) {
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
filterChain.doFilter(request, response); // ✅ 유효한 경우 요청 처리
return;
}
}
// ❗ 인증 실패 시 403 Forbidden 반환
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("Forbidden: Invalid or missing token");
}
}
SecurityConfig.jvava 는 이 서버의 보안설정이다. csrf 기능을 사용할것인지, 그리고 어떤부분에서 jwt 인증을 사용할것인지 등의 설정을 저장한다.
package com.esa.rpg_server.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtUtil jwtUtil;
public SecurityConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화 (API 서버에서는 일반적)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register/**","/api/auth/login/**").permitAll() // 로그인은 허용
.anyRequest().authenticated() // 나머지는 인증 필요
)
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
왜 SecurityConfig.java 와 JwtAuthenticationFilter.java 두군데에서 jwt 비인증 api를 검사하는걸까??
Api 요청이 들어오면 먼저 SecurityConfig에 따라 요청을 처리할지 결정한다. 그러나 JwtAuthenticationFilter는 모든 요청이 거쳐가야만 하고, 필터가 이미 실행되어버렸기 때문에 토큰검사를 피할 수가 없다고 한다.
이중검사가 필요한 이유는 다음과 같다.
- 보안강화 : 특정 API가 나중에 permitAll() 설정이 바뀌더라도 , JWT 필터는 항상 존재하기 때문에, 잠재적 보안이슈를 줄일 수 있다.
- 로깅 및 모니터링 : 인증이 필요없는 요청이더라도 JWT가 있다면 검증 후 로깅이 가능하다. 이는 사용자 활동 추적에 용이하다.
- 통일성 유지 : 모든 요청은 JWT 필터를 거치므로, 코드의 일관성이 유지된다. 불필요한 예외처리를 줄일 수 있다.
개발 이슈 : 401 unAuthorized Error
이 문제는 로그인/가입 시에는 JWT 인증을 하지 않아야하는데 인증을 시도하고 있고, 인증에 실패하여 발생하는 오류이다. SecurityConfig.java에 내가 작성한 API와 동일한 경로가 적혀있는지를 확인해야한다. 나의 경우 /api/auth/register/.. 이었는데 /auth/ 부분을 빼먹어서 발생한 에러였다.
또, 만약에 JwtAuthenticationFilter의 requestURI를 이용해 로그인/가입 api를 거르는 부분에도 오타가있다면 401 unAuthorizedError가 발생한다.
개발이슈 : 404 Forbidden
notNull 인 필드에 null이 들어갔다는 에러문이 발생하며, 클라이언트에서는 404 Forbidden 에러가 발생했다. 에러문은 다음과같다. not-null 인 property 인 backup 이 null로 설정되었다는 에러이다.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
org.springframework.dao.DataIntegrityViolationException:
not-null property references a null or transient value:
com.esa.rpg_server.domain.backup.Backup.data] with root cause
새로 가입한 유저는 백업정보가 없기 때문에 backups 필드가 null로 설정된다. 그러나 정의할때는 backups 를 notnull로 설정했다. backups의 nullable = true로 하거나, 유저 가입시 비어있지 않게끔 설정해주면 된다. 나의 경우 backups는 null로 설정하되, backup 안에있는 user는 null이면 안되기 때문에, user생성시에 backup과 매칭을해주었다.
package com.esa.rpg_server.domain.user;
@Entity
@Table(name = "users")
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "uid", unique = true, nullable = false)
private String uid; // 고유 ID (게스트/정식 유저 공통)
@Column(name = "username")
private String username; // 정식 유저 이름 (게스트는 NULL 가능)
@Column(name = "login_type", nullable = false)
private int loginType; // 로그인 타입 (0: 게스트, 1: 이메일, 2: 소셜 등)
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Backup backup;
public User() {
super();
backup = new Backup();
backup.setUser(this);
}
}
다음 목표는!?
- 클라이언트 : 액세스토큰을 보유하고, 로그인 이후 통신에 엑세스토큰과 함께 API요청하기
- 서버 : 로그인/가입을 제외한 나머지 통신에서 엑세스토큰을 검사하기.
'Server > 방치RPG 서버' 카테고리의 다른 글
[방치RPG 서버 제작기] 1. 프로젝트 생성 + 통신 구현 (1) | 2025.01.26 |
---|