익명 채팅 서비스에서 사용자의 접근성을 높이기 위해 회원가입/로그인은 구현하지않는 선택을 했습니다. 그렇다면 사용자는 어떻게 식별할 수 있을까요?
저는 SpringSeucirty + JWT(AccessToke) 발급을 기반으로 사용자의 신원을 구분할 수 있도록 했습니다.
사용자의 정보를 기반으로 UUID(FingerPrint) 를 생성했습니다.
사용자의 환경 정보를 조합해 유니크한 식별자를 만드는 방식입니다.
프로젝트에서는 사용자의 IP, Browser의 User-Agent 정보의 조합을 사용해 UUID를 생성했습니다. 브라우저나 네트워크 환경이 바뀌면 해당 값이 달라질 수 있지만, 단체 채팅 특성상 크게 문제가 되지는 않을것으로 예상했습니다.
String ipAddress = IpAddressUtil.extractIpAddress(request);
String userAgent = request.getHeader("User-Agent");
// 간단한 UUID 생성 (Base64 인코딩)
String UUID = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString((ipAddress + userAgent).getBytes());
로그인 과정이 없기에, 최초 접속시 AccessToken을 발급하여 닉네임을 랜덤하게 발급합니다. JWT를 발급 과정을 간단하게 코드를 통해 알아보겠습니다.
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter,
JwtProvider jwtProvider) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())) // CORS 허용
// Authentication
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form.disable())
.authenticationProvider(jwtAuthenticationProvider)
// Authorization
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll() // 정책
)
.build(); // 모든 요청 허용
}
오직 사용자 Authentication(인증)이 필요하기 때문에 별도의 Authorization(인가) 처리는 하지 않습니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 해당 Path 는 validate X
private static final List<SecuredPath> UNSECURED_API_PATHS = List.of(
new SecuredPath(HttpMethod.GET, "/api/v1/auth/init"),
new SecuredPath(HttpMethod.GET, "/api/v1/auth/refresh"),
new SecuredPath(HttpMethod.GET, "/api/v1/message")
);
...
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
HttpMethod method = HttpMethod.valueOf(request.getMethod());
// 필터 적용 대상만 필터를 타도록 (shouldNotFilter = true)
boolean matches = UNSECURED_API_PATHS.stream()
.anyMatch(sp -> sp.matches(method, path));
return matches;
}
CustomFilter에서도 init(발급), refresh(재발급), message(발행) 요청 시에만 신원 인증을 처리할 수 있도록 sholudNotFilter 처리를 해줍니다.
...
public String generateAccessToken(String userName, String imgPath, HttpServletRequest request) throws UnknownHostException {
Instant now = Instant.now();
// 랜덤 프로필 생성
if (userName == null && imgPath == null) {
List<String> profileList = generateRandomName();
userName = profileList.get(0);
imgPath = profileList.get(1);
}
// FingerPrint UUID
String ipAddress = IpAddressUtil.extractIpAddress(request);
String userAgent = request.getHeader("User-Agent");
String UUID = Base64.getUrlEncoder().withoutPadding()
.encodeToString((ipAddress + userAgent).getBytes());
return Jwts.builder()
.id(UUID)
.issuer(serverIdentifier) // 서버 식별자
.claim("userName", userName)
.claim("imgPath", imgPath)
.audience().add("mapleland").and()
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(expirationSeconds)))
.signWith(key)
.compact();
}
// RefreshToken 이하 동일..
...
익명성을 최대한 보장하며 UX(사용자 식별 및 구분)를 위해 랜덤 몬스터 이름 + 번호를 조합해 닉네임을 생성했습니ek.
보통 JWT Refresh Token은 LocalStorage에 저장하기도 하지만 이는 다음과 같은 탈취의 위험이 있습니다.
- XSS(크로스 사이트 스크립팅) 공격
- LocalStorage는 브라우저 자바스크립트 코드로 접근이 가능하기 때문에, 공격자가 스크립트를 삽입하여 토큰을 탈취할 수 있다. HttpOnly 속성을 통해 이를 제한할 수 있다.
- SameSite/Cookie 기반 보호 불가
- 완전히 다른 도메인에 대해서는 쿠키 요청시 전송을 제한한다.
- CSRF(Cross-Site Request Forgery)
- 사용자의 의도와 상관없이 공격자가 만든 요청이 피해자의 세션으로 보내지는 공격이다. 쿠키는 매 요청시 자동 전송되기 떄문에 해당 문제가 발생한다.
이러한 문제를 방지하고자 RefreshToken은 HttpOnly Secure 쿠키로 발급하여 클라이언트측에서 관리하도록 했습니다.
...
private TokenResponseDto issueTokens(String userName, String imgPath,
HttpServletRequest request,
HttpServletResponse response) throws UnknownHostException {
String accessToken = jwtProvider.generateAccessToken(userName, imgPath, request);
String refreshToken = jwtProvider.generateRefreshToken(userName, imgPath, request);
// Refresh Token을 HttpOnly Cookie에 저장
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true) // JS 접근 불가 (XSS 방지)
.secure(true) // HTTPS 전송만 허용 (평문 노출 방지)
.sameSite("None") // SPA 환경상 None 제한
.path("/api/v1/auth")
.maxAge(Duration.ofDays(7)) // 유효기간 7일
.build();
response.addHeader("Set-Cookie", cookie.toString());
return TokenResponseDto.builder()
.accessToken(accessToken)
.build();
}
다음 세 가지 속성을 두어 보안성을 높였습니다.
HttpOnly → 자바스크립트로 접근 불가 (XSS 방지)
Secure → HTTPS에서만 전송
SameSite=None → 크로스 도메인에서도 쿠키 전송 가능
@Service
@RequiredArgsConstructor
public class AuthDomainService {
private final RedisService redisService;
private final JwtProvider jwtProvider;
private final BadWordFilter badWordFilter;
...
public TokenResponseDto refreshAuth(String refreshToken, HttpServletRequest request, HttpServletResponse response) {
TokenClaims claims = jwtProvider.validateToken(refreshToken);
String userName = claims.getUserName();
String imgPath = claims.getImgPath();
try {
return issueTokens(userName, imgPath, request, response);
} catch (UnknownHostException e) {
throw new AuthException.UnknownHostException("refreshAuth Failed: IP 추출 실패");
}
}
...
private TokenResponseDto issueTokens(String userName, String imgPath, HttpServletRequest request, HttpServletResponse response) throws UnknownHostException {
String AccessToken = jwtProvider.generateAccessToken(userName, imgPath, request);
String RefreshToken = jwtProvider.generateRefreshToken(userName, imgPath, request);
ResponseCookie cookie = ResponseCookie.from("refreshToken", RefreshToken)
.httpOnly(true)
.secure(true)
.sameSite("None")
.path("/api/v1/auth")
.maxAge(Duration.ofDays(7))
.build();
;
response.addHeader("Set-Cookie", cookie.toString());
return TokenResponseDto.builder()
.accessToken(AccessToken)
.build();
}
}
또한, AccessToken의 만료시에 RefreshToken의 정보를 재활용(userName, imgPath) 하여 JWT를 재발급했습니다. (사용자 식별을 유지하기 위해서)
트레이드 오프 관점에서, 로그인 없는 단체 채팅 서비스라는 목적에 맞춰 보안과 접근성 사이의 균형을 잡는 선택을 했습니다.