[내배캠_단기 게임 서버 개발 부트캠프] 인증/인가 정리 - Session, JWT, Spring Security

오수호·6일 전

TIL

목록 보기
73/75

이번엔 로그인 구현의 기초인 인증/인가 개념부터 시작해서, 세션 기반 인증과 JWT를 비교하고, 마지막엔 Spring Security로 JWT 인증·인가를 실제로 구현하는 부분까지 쭉 이어지는 내용을 배웠다. 흐름이 하나로 연결돼 있어서 한 번에 정리해본다.


1. 인증과 인가

  • 인증(Authentication): 누구인지 확인하는 절차 → 쉽게 말하면 로그인
  • 인가(Authorization): 특정 행위를 할 수 있는지 확인하는 절차 → 쉽게 말하면 권한

인증을 먼저 하고 그다음 인가가 이루어진다. 인증 실패 시 401 Unauthorized, 인가 실패 시 403 Forbidden을 내려준다.

실무에서는 이 둘을 줄여서 AuthN(인증) / AuthZ(인가)라고도 부른다. 권한을 판단하는 방식도 한 가지만 있는 건 아닌데, 오늘 다룰 Role 기반 방식(RBAC, Role-Based Access Control)이 가장 널리 쓰이고, 더 세밀하게는 사용자 속성·리소스 속성까지 조합해 판단하는 ABAC(Attribute-Based Access Control) 같은 모델도 있다.


2. 세션(Session) 기반 인증

세션은 사용자의 로그인 정보를 서버가 보관하는 저장 공간, 또는 그 방식을 뜻한다. 서버가 상태를 들고 있다는 의미에서 Stateful이라고 부른다.

  • 스프링에서는 JSESSIONID라는 Session Key로 세션을 세팅하고 쿠키에 담아 응답한다 (Server → Client 쿠키 이름: Set-Cookie)
  • Client는 자동으로 세팅된 쿠키를 HTTP 요청에 담아 보낸다 (Client → Server 쿠키 이름: Cookie)
  • Server는 JSESSIONID의 Session Key로 세션 저장소에서 세션(유저 정보)을 찾아 사용한다

세션 저장소는 기본적으로 Spring 내부(서블릿 컨테이너) 메모리에서 동작하고, 외부 저장소를 붙여 쓸 수도 있다.

기본 코드 구조

@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true)
    private String email;
    @Column(nullable = false)
    private String password;
    public Member(String email, String password) {
        this.email = email;
        this.password = password;
    }
}
@Service
@RequiredArgsConstructor
public class AuthService {
    private final MemberRepository memberRepository;

    @Transactional
    public MemberResponse signup(SignupRequest request) {
        Member member = memberRepository.save(new Member(request.getEmail(), request.getPassword()));
        return new MemberResponse(member.getId(), member.getEmail());
    }

    @Transactional(readOnly = true)
    public MemberResponse authenticate(LoginRequest request) {
        Member member = memberRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
        if (!member.getPassword().equals(request.getPassword())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        return new MemberResponse(member.getId(), member.getEmail());
    }
}

로그인 성공 시 세션을 생성하고, @SessionAttribute로 로그인 정보를 꺼내 쓴다.

@PostMapping("/login")
public ResponseEntity<MemberResponse> login(
        @RequestBody LoginRequest body,
        HttpServletRequest request
) {
    MemberResponse member = authService.authenticate(body);
    HttpSession session = request.getSession();
    request.changeSessionId();
    session.setAttribute(SessionConst.LOGIN_MEMBER_ID, member.getId());
    return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(member);
}

@GetMapping("/me")
public ResponseEntity<MemberResponse> me(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER_ID, required = false) Long memberId
) {
    return ResponseEntity.ok().cacheControl(CacheControl.noStore())
            .body(authService.requireMember(memberId));
}

세션 키 이름은 문자열로 직접 쓰지 않고 상수 클래스로 관리한다. 오타로 인한 버그 가능성을 없애기 위해서다.

public abstract class SessionConst {
    public static final String LOGIN_MEMBER_ID = "LOGIN_MEMBER_ID";
}

세션 만료는 설정값 하나로 끝난다.

server.servlet.session.timeout=30m

세션 방식의 가장 큰 약점은 확장성이다. 서버가 로그인 상태를 자기 메모리에 들고 있기 때문에, 서버를 여러 대로 수평 확장하면 문제가 생긴다. A 서버에서 로그인한 사용자의 다음 요청이 로드밸런서를 거쳐 B 서버로 가면, B 서버는 그 세션을 모른다. 이를 해결하려면 같은 사용자를 항상 같은 서버로 보내는 스티키 세션(sticky session)을 쓰거나, Redis 같은 외부 저장소에 세션을 공유해야 한다. 여러 서버 인스턴스를 두고 매칭·로드밸런싱을 하는 게임 서버 환경에서는 이 문제가 특히 자주 부딪히는 지점이라, 다음에 볼 JWT의 Stateless한 특성이 왜 매력적인지가 자연스럽게 연결된다.


3. JWT (JSON Web Token)

JWT는 {header값}.{payload값}.{signature} 구조로 이루어져 있다. 정보를 JSON으로 표현해 전달하는 토큰 형식으로, 회원 ID나 역할 같은 정보를 담아 시스템 사이에서 주고받을 때 쓴다.

  • Header: 서명 알고리즘 등 토큰 처리에 필요한 정보
  • Payload: 회원 ID, 역할 등 실제 데이터(클레임)
  • Signature: (Header + Payload)와 Key로 만든 검증용 값

JWT는 2010년 IETF Internet-Draft로 처음 발행됐고, 2015년 RFC 7519로 공식 표준화됐다.

클레임은 JWT에 담는 정보의 각 항목이다. 표준으로 정해진 이름도 있고 애플리케이션이 자유롭게 정할 수도 있다.

  • sub: 회원 ID가 주로 담기는 토큰의 주체
  • iat: 발급 시각
  • exp: 만료 시각
  • role: 애플리케이션이 정한 사용자 역할(표준 등록 클레임 아님)
구분SessionJWT
상태 관리Stateful (서버가 보관)Stateless (토큰 자체에 정보 포함)
저장 위치서버 메모리 / 외부 저장소클라이언트가 보관
서버 확장세션 클러스터링·Sticky Session 필요서버 간 상태 공유 불필요
강제 무효화서버에서 즉시 가능 (session.invalidate())만료 전엔 어려움

Signature는 {암호화알고리즘}(Base64Url(Header) + "." + Base64Url(Payload), SecretKey)로 만든다. Base64URL은 일반 Base64에서 URL에 문제가 될 수 있는 특수문자(+, /, =)를 처리한 방식이다. (테스트는 jwt.io, Base64 인코딩은 온라인 Base64 도구로 확인했다.)

서명 알고리즘에는 크게 두 갈래가 있다. 오늘 쓴 HS256은 대칭키 방식이라 발급자와 검증자가 같은 비밀키를 공유해야 하는데, RS256 같은 비대칭키 방식은 개인키로 서명하고 공개키로 검증하기 때문에 여러 서비스가 공개키만 나눠 갖고도 토큰을 검증할 수 있어 마이크로서비스 환경에서 자주 쓰인다.

JWT의 Stateless한 특성은 확장성 면에서 큰 장점이지만 트레이드오프도 있다. 서버가 토큰을 따로 저장하지 않기 때문에, 이미 발급된 토큰을 로그아웃이나 탈취 상황에서 즉시 무효화하기가 어렵다. 그래서 실무에서는 Access Token의 만료 시간을 짧게 잡고, 별도의 Refresh Token으로 재발급하는 방식을 함께 쓰는 경우가 많다.


4. 비밀번호 암호화와 JWT 발급

비밀번호 암호화

@Configuration
@EnableMethodSecurity
public class AuthenticationConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

passwordEncoder.encode()는 비밀번호를 암호화하고, matches()는 요청으로 들어온 비밀번호를 DB에 저장된 암호화된 비밀번호와 대조한다. @EnableMethodSecurity는 뒤에서 @PreAuthorize로 Role 기반 인가를 할 때 쓴다.

BCryptPasswordEncoder를 쓰는 이유는 단순 SHA-256 해시와 다르다. BCrypt는 매번 다른 salt를 자동으로 섞어 같은 비밀번호라도 다른 해시값이 나오게 해서 레인보우 테이블 공격을 무력화하고, 해시 계산 비용(work factor)을 의도적으로 조절할 수 있는 적응형 해시 함수(adaptive hash function)라서 무차별 대입 공격에도 더 강하다.

JWT 발급(Access Token)

의존성:

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-gson:0.12.6'
@Component
public class JwtProvider {
    private final SecretKey secretKey;
    private final long accessTtlSeconds;

    public JwtProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.access-ttl-seconds}") long accessTtlSeconds
    ) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
        this.accessTtlSeconds = accessTtlSeconds;
    }

    public String createAccessToken(User user) {
        Instant now = Instant.now();
        Instant expiresAt = now.plusSeconds(accessTtlSeconds);
        return Jwts.builder()
                .subject(user.getId().toString())
                .claim("role", user.getRole().name())
                .issuedAt(Date.from(now))
                .expiration(Date.from(expiresAt))
                .signWith(secretKey, Jwts.SIG.HS256)
                .compact();
    }

    public AuthUser parseToken(String token) {
        try {
            Jws<Claims> signedClaims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
            Claims claims = signedClaims.getPayload();
            String subject = claims.getSubject();
            long userId = Long.parseLong(subject);
            Role role = Role.valueOf(claims.get("role", String.class));
            return new AuthUser(userId, role);
        } catch (JwtException | IllegalArgumentException | NullPointerException exception) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
    }
}
@Service
@RequiredArgsConstructor
public class JwtAuthService {
    private final CredentialService credentialService;
    private final JwtProvider jwtProvider;

    @Transactional(readOnly = true)
    public AccessTokenResponse login(LoginRequest request) {
        User user = credentialService.authenticate(request);
        String accessToken = jwtProvider.createAccessToken(user);
        return new AccessTokenResponse(accessToken);
    }
}

로그인 성공 시 JWT(=accessToken)를 HTTP body에 담아 응답한다.


5. Spring Security로 JWT 검증하기

JwtAuthenticationFilter는 Spring Security Filter Chain에 등록된 필터로, HTTP 요청 Header(Authorization)에서 JWT를 꺼내 파싱한 뒤 JwtAuthenticationTokenAuthUser를 담는다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
            filterChain.doFilter(request, response);
            return;
        }
        AuthUser authUser;
        try {
            String token = authorization.substring(7);
            authUser = jwtProvider.parseToken(token);
        } catch (ResponseStatusException exception) {
            SecurityContextHolder.clearContext();
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }
        SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + authUser.getRole().name());
        Authentication authentication = new JwtAuthenticationToken(authUser, List.of(authority));
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);
        try {
            filterChain.doFilter(request, response);
        } finally {
            SecurityContextHolder.clearContext();
        }
    }
}

OncePerRequestFilter를 상속하는 이유가 있다. 서블릿 컨테이너에서는 요청이 forward/include로 내부적으로 재전달되는 경우가 있는데, 일반 Filter를 그대로 쓰면 같은 필터가 한 요청 안에서 중복 실행될 수 있다. OncePerRequestFilter는 요청당 정확히 한 번만 실행되도록 보장해준다.

JwtAuthenticationTokenAuthentication 객체로, SecurityContext를 통과하기 위해 필요하다. Authentication 객체가 없으면 인증이 필요한 API는 접근이 차단된다.

public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private final AuthUser authUser;

    public JwtAuthenticationToken(AuthUser authUser, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.authUser = authUser;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}

Authentication 객체의 getPrincipal()이 반환하는 값이 컨트롤러에서 @AuthenticationPrincipal 사용 시 주어지는 정보다.

@GetMapping("/me")
public ResponseEntity<UserResponse> me(
        @AuthenticationPrincipal AuthUser authUser
) {
    return ResponseEntity.ok(userService.findMe(authUser.getUserId()));
}

이 필터가 별도 등록 없이도 동작하는 이유는 SecurityFilterChain 빈에 등록하기 때문이다.

@Configuration
public class JwtSecurityConfig {
    @Bean
    public SecurityFilterChain jwtSecurityFilterChain(
            HttpSecurity http,
            JwtProvider jwtProvider
    ) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) // 쿠키 인증 없이 Bearer 헤더를 쓰므로 CSRF 보호 끄기
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 요청마다 JWT를 검증하므로 인증용 세션 사용 안 함
                .requestCache(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable) // JSON 로그인 API를 쓰므로 기본 로그인 폼 끄기
                .httpBasic(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable) // 세션 기반 로그아웃 끄기
                .authorizeHttpRequests(authorize -> authorize
                        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
                        .requestMatchers(HttpMethod.GET, "/posts").permitAll()
                        .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/admin/signup").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint((request, response, exception) ->
                                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
                        .accessDeniedHandler((request, response, exception) ->
                                response.setStatus(HttpServletResponse.SC_FORBIDDEN)))
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), AuthorizationFilter.class);
        return http.build();
    }
}

Filter Chain은 각 필터가 자기 역할만 처리하고 다음 필터로 넘기는 체인 오브 리스판서빌리티(Chain of Responsibility) 패턴의 실제 사례다. addFilterBefore로 순서를 지정하는 이유는, Spring Security가 이미 등록해둔 기본 필터(예: AuthorizationFilter)보다 JWT 인증이 먼저 끝나야 인가 판단이 제대로 이루어지기 때문이다.


6. Role 기반 인가

앞서 본 SecurityFilterChain.requestMatchers("/admin/**").hasRole("ADMIN")처럼, 특정 URL 패턴에 Role 조건을 걸 수 있다.

메서드실제 비교되는 권한 문자열
hasRole("ADMIN")ROLE_ADMIN
hasAuthority("ROLE_ADMIN")ROLE_ADMIN
hasRole("USER")ROLE_USER

권한 문자열에 ROLE_ 접두사가 붙는 것은 Spring Security만의 고유 규칙이다. hasRole은 내부적으로 ROLE_ 접두사를 자동으로 붙여주고, hasAuthority는 접두사 없이 전달한 문자열을 그대로 비교한다. 즉 hasRole("ADMIN")hasAuthority("ROLE_ADMIN")의 축약형인 셈이다.

메서드 단위로도 같은 걸 할 수 있다. @EnableMethodSecurity가 켜져 있으면 @PreAuthorize로 권한 검사를 할 수 있다.

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> users() {
    return ResponseEntity.ok(userService.findAll());
}

이건 오늘 다룬 Role 기반 인가 자체가 RBAC(Role-Based Access Control)이라는 표준 접근 제어 모델을 Spring Security 위에서 구현한 것이다. Role 하나로는 부족할 만큼 권한이 세분화돼야 하는 경우엔, Role 대신 POST_WRITE, POST_DELETE처럼 더 잘게 쪼갠 개별 Authority를 여러 개 부여하는 방식도 쓸 수 있다.

Postman으로 JWT 인증 API를 테스트하는 방법: Authorization 탭에서 Type을 Bearer Token으로 설정하고, 로그인 API 응답에 담긴 JWT를 Token 입력란에 붙여넣으면 요청 Header에 Authorization: Bearer ...가 자동으로 추가된다. ADMIN으로 로그인한 JWT로 ADMIN 전용 API를 요청하면 200 OK, USER로 로그인한 JWT로는 403 Forbidden, JWT 없이 요청하면 401 Unauthorized가 내려온다.


7. 접근 제어와 IDOR

게시글을 만들 때 작성자 ID를 요청 본문(@RequestBody)이나 URL Path(@PathVariable)로 그대로 받으면, 클라이언트가 다른 회원의 ID를 넣어 그 사람 이름으로 글을 쓰거나 지울 수 있다. 이건 OWASP Top 10에서도 다루는 대표적인 보안 취약점인 Broken Access Control(구 명칭 IDOR, Insecure Direct Object Reference)에 해당한다.

해결 방법은 회원 ID를 클라이언트 입력이 아니라, JWT를 검증해서 얻은 AuthUser에서 꺼내 쓰는 것이다.

@RestController
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;

    @PostMapping("/posts")
    public ResponseEntity<PostResponse> create(
            @AuthenticationPrincipal AuthUser authUser
    ) {
        PostResponse post = postService.create(authUser.getUserId());
        return ResponseEntity.status(HttpStatus.CREATED).body(post);
    }

    @DeleteMapping("/posts/{id}")
    public ResponseEntity<Void> delete(
            @PathVariable long id,
            @AuthenticationPrincipal AuthUser authUser
    ) {
        postService.delete(id, authUser.getUserId());
        return ResponseEntity.noContent().build();
    }
}
@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    @Transactional
    public void delete(long postId, long authenticatedUserId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        if (post.getUser().getId() != authenticatedUserId) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN);
        }
        postRepository.delete(post);
    }
}

delete()는 게시글을 먼저 조회한 뒤, 게시글의 작성자 ID와 요청한 회원 ID를 비교한다. 두 값이 같으면 삭제하고, 다르면 거절한다. 회원 ID를 요청이 아니라 인증 정보에서 가져온다는 원칙 하나만 지키면 이런 종류의 취약점은 대부분 막을 수 있다.

profile
게임개발자 취준생입니다

0개의 댓글