01장 로그인은 왜 어려운가

로그인 기능을 처음 만들 때는 별것 없어 보인다.
이메일로 사용자를 찾고 비밀번호를 비교한다. 맞으면 성공, 틀리면 실패다.

진짜 문제는 그다음 요청부터 생긴다.

사용자가 로그인을 마치고 /my-page를 호출했다고 해보자. 서버는 이 요청을 보낸 사람이 조금 전에 로그인한 사용자라는 걸 알아야 한다. 그런데 HTTP 요청만 놓고 보면 둘 사이에는 아무런 연결이 없다.

로그인은 비밀번호 비교보다 "다음 요청에서도 이 사용자를 어떻게 알아볼 것인가"를 해결하는 일이 더 크다.

대부분의 주니어가 개발할 때 겪게되는 만악의 근원은 상태관리에서 온다.

그러나...HTTP는 아무것도 기억하지 않는다

HTTP는 요청 하나를 처리하고 나면 그 요청과 다음 요청의 관계를 기본적으로 기억하지 않는다.

POST /login

GET /my-page

브라우저에서는 연속해서 일어난 일이지만 서버가 받은 것은 독립된 HTTP 요청 두 개다. 두 번째 요청이 첫 번째 요청과 같은 사람이 보낸 것이라는 정보는 HTTP 자체에 없다.

IP 주소로 묶는 것도 방법이 아니다. 회사나 가정에서는 여러 사용자가 같은 공인 IP를 쓸 수 있고, 모바일 환경에서는 사용 중에도 IP가 바뀐다. 프록시와 로드 밸런서까지 끼면 더 믿기 어려워진다.

그래서 로그인 이후에는 클라이언트가 자신을 식별할 정보를 계속 보내야 한다.

세션과 토큰의 차이는 여기서 출발한다. 세션은 서버 쪽에 로그인 상태를 두고, 토큰 방식은 요청에 실린 증표로 사용자를 식별한다.

인증(Authentication)과 인가(Authorization)

인증과 인가는 자주 붙어 다니지만 확인하는 대상이 다르다.

인증사용자가 누구인지 확인하는 과정이다. 이메일과 비밀번호로 로그인하거나, 유효한 Access Token을 보내거나, 세션 ID로 기존 로그인 상태를 찾는 일이 여기에 해당한다.

인가에서는 이미 확인된 사용자가 해당 작업을 해도 되는지 판단한다.

예를 들어 ROLE_USER 권한을 가진 사용자가 다음 API를 호출했다고 하자.

DELETE /admin/users/100

로그인 자체는 정상이다. 누가 요청했는지도 알고 있다. 하지만 관리자 권한은 없다. 인증에는 성공했고 인가에서 거부되는 경우다.

Spring Security의 Authentication 객체에도 이 구분이 드러난다.

Authentication
 ├─ principal
 ├─ authenticated
 └─ authorities

principal은 현재 사용자를 나타내고 authorities에는 사용자가 가진 권한이 들어간다.

401과 403의 차이점

401과 403도 인증과 인가를 기준으로 보면 덜 헷갈린다.

401 Unauthorized는 요청자를 정상적으로 인증할 수 없을 때 사용한다.

Access Token이 없거나, 만료됐거나, 서명이 맞지 않는 경우가 대표적이다.

GET /my-page
Authorization: Bearer invalid-token

서버는 요청자가 누구인지 확정할 수 없으므로 401을 돌려준다.

HTTP/1.1 401 Unauthorized

403 Forbidden은 사용자가 누구인지는 알지만 그 작업을 허용할 수 없을 때 사용한다.

일반 사용자가 관리자 API를 호출하는 경우가 그렇다.

Spring Security에서는 보통 AuthenticationEntryPoint가 401 응답을, AccessDeniedHandler가 403 응답을 담당한다.

결론: 서버가 기억하거나, 증표를 주거나

로그인 이후에도 사용자를 식별하려면 HTTP 요청에 뭔가를 더 얹어야 한다.

세션 방식은 서버에 로그인 상태를 저장하고 클라이언트에게 세션 ID를 준다.

Cookie: JSESSIONID=8A7F...

JWT를 이용한 방식에서는 서버가 서명한 토큰을 발급하고 클라이언트가 그 토큰을 들고 다닌다.

Authorization: Bearer eyJ...

뒤에서 다룰 세션, JWT, Refresh Token 이야기는 전부 이 차이에서 이어진다.


02장 비밀번호는 어떻게 저장하는가

회원가입 API로 이런 요청을 받았다고 하자.

{
  "email": "user@example.com",
  "password": "12345678"
}

12345678을 그대로 DB에 넣으면 안 된다는 건 쉽게 알 수 있다. DB가 한 번 유출되는 순간 비밀번호도 함께 공개되기 때문이다.

그렇다고 일반적인 암호화를 쓰는 것도 맞지 않는다. 암호화는 나중에 원문을 복호화할 수 있다. 비밀번호 인증에서는 서버가 사용자의 원래 비밀번호를 다시 알아낼 이유가 없다.

서버가 알고 싶은 건 입력한 비밀번호와 가입할 때 입력했던 비밀번호가 같은지뿐이다.

그래서 비밀번호는 단방향 함수로 변환한 값을 저장한다.

SHA-256에 salt를 더해도 부족한 이유

SHA-256으로 비밀번호를 해싱하면 평문 저장 문제는 피할 수 있다.

password → SHA-256 → hash

하지만 같은 입력은 항상 같은 결과를 만든다. 공격자는 자주 쓰이는 비밀번호의 해시 값을 미리 계산해둘 수 있다.

여기에 salt를 붙이면 같은 비밀번호라도 사용자마다 다른 해시가 나온다.

SHA-256(password + randomSalt)

레인보우 테이블 같은 사전 계산 공격에는 훨씬 강해진다.

그래도 비밀번호 저장용으로는 부족하다. SHA-256이 너무 빠르기 때문이다.

일반적인 해시 함수에서 속도는 장점이다. 비밀번호 공격에서는 그 장점이 공격자에게도 똑같이 주어진다. GPU나 전용 하드웨어로 후보 비밀번호를 빠르게 대입할 수 있다.

비밀번호 해시에는 빠른 함수보다 반복 계산 비용이 큰 함수를 쓰는 편이 낫다.

BCrypt는 해시를 일부러 느리게 만든다

BCrypt는 비밀번호 검증에 일부러 시간을 쓰도록 설계됐다.

Spring Security에서는 BCryptPasswordEncoder를 사용할 수 있다.

PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

String encoded = passwordEncoder.encode(password);

BCrypt에는 cost 값이 있다. 값을 높일수록 한 번 해싱하는 데 필요한 계산량도 늘어난다.

사용자가 로그인할 때 수십~수백 ms가 걸리는 것은 서비스에서 감당할 수 있을 수 있다. 공격자가 수백만, 수억 개의 후보를 대입하려 하면 같은 비용이 그대로 누적된다.

BCrypt는 salt도 내부에서 처리한다. 그래서 같은 문자열을 여러 번 encode()해도 결과가 같지 않다. 두 해시 문자열을 equals()로 비교해서는 안 되는 이유다.

따라서 비밀번호 비교에는 matches()를 쓴다.

passwordEncoder.matches(rawPassword, encodedPassword);

Argon2id는 메모리까지 요구한다

Argon2는 계산 시간뿐 아니라 메모리 사용량도 조절할 수 있다.

비밀번호 하나를 검사하는 데 일정량의 메모리가 필요하도록 만들면 대규모 병렬 공격 비용이 더 커진다. GPU가 빠르더라도 동시에 처리하려는 각 작업에 메모리가 필요하기 때문이다.

Argon2id에서는 대략 다음 값을 조절한다.

  • memory cost
  • time cost
  • parallelism

Spring Security에는 Argon2PasswordEncoder가 있다.

BCrypt와 Argon2id 중 무엇을 쓰든 목표는 같다. 정상적인 로그인 한 번에는 감당할 수 있고, 공격자가 같은 연산을 수백만 번 반복하기에는 비싼 조건을 만드는 것이다.

레거시 해시는 로그인할 때 다시 해싱한다

운영 중인 서비스라면 비밀번호 정책을 한 번에 바꾸기 어렵다.

예전에 BCrypt cost 8로 저장했고 이제 cost 12로 올리고 싶다고 해보자.

원래 비밀번호를 모르기 때문에 DB에 저장된 해시만 가지고 새로운 해시를 만들 수는 없다. 해시를 다시 해싱하는 것은 사용자의 비밀번호를 새 정책으로 저장하는 것과 다른 결과다.

대신 사용자가 로그인하는 순간을 이용한다.

  1. 기존 해시로 비밀번호를 검증한다.
  2. 로그인에 성공한다.
  3. 현재 저장된 해시가 구버전 정책인지 확인한다.
  4. 사용자가 방금 입력한 원본 비밀번호를 새 정책으로 다시 해싱한다.
  5. DB의 비밀번호 해시를 갱신한다.

사용자가 로그인할 때마다 조금씩 새 정책으로 넘어가는 방식이다.

알고리즘을 BCrypt에서 다른 방식으로 바꾸는 경우에도 같은 전략을 쓸 수 있다. Spring Security의 DelegatingPasswordEncoder는 서로 다른 인코딩 방식을 함께 처리할 때 도움이 된다.

User 엔티티와 회원가입

간단한 회원 엔티티부터 만들어보자.

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    public User(String email, String password, Role role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }
}

회원가입 서비스에서는 저장 직전에 비밀번호를 인코딩한다.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public Long signup(SignupRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
        }

        String encodedPassword = passwordEncoder.encode(request.password());

        User user = new User(
                request.email(),
                encodedPassword,
                Role.USER
        );

        return userRepository.save(user).getId();
    }
}

PasswordEncoder도 Bean으로 등록한다.

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

DB에는 원본 비밀번호가 아니라 BCrypt 문자열만 남는다.

로그인할 때도 원본 비밀번호를 복구하지 않는다.

passwordEncoder.matches(request.password(),user.getPassword());

비밀번호 저장에서는 "다시 알아낼 수 없게 저장하고, 비교만 가능하게 만든다"는 원칙이 중요하다.


03장 Spring Security의 구조

Spring Security가 어려워 보이는 이유 중 하나는 클래스 이름이 많기 때문이다.

SecurityFilterChain, Authentication, AuthenticationManager, AuthenticationProvider, UserDetailsService, SecurityContextHolder가 한꺼번에 등장한다.

각 타입의 정의를 외우기보다 로그인 요청이 어디를 지나가는지 보는 편이 빠르다.

Spring Security는 필터 체인이다

Spring MVC만 보면 요청은 DispatcherServlet을 거쳐 Controller로 간다.

Spring Security를 붙이면 그 앞에 여러 Servlet Filter가 놓인다.

Request
  ↓
Security Filter Chain
  ↓
DispatcherServlet
  ↓
Controller

필터마다 맡은 일이 다르다. 로그인 요청을 처리하는 필터도 있고, 현재 인증 정보를 읽어오는 필터도 있으며, 인가 과정에서 발생한 예외를 처리하는 필터도 있다.

우리가 작성하는 SecurityFilterChain 설정은 이 동작을 조립하는 코드에 가깝다.

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/login", "/signup")
        .permitAll()
        .anyRequest()
        .authenticated()
    );

    return http.build();
}

여기서 permitAll()은 해당 URL이 Security Filter Chain을 건너뛴다는 뜻이 아니다. 필터 체인은 그대로 지나가되 인증 없이 접근할 수 있도록 인가 규칙을 여는 것이다.

SecurityContextHolder에 인증 정보가 담긴다

현재 요청에서 인증된 사용자는 SecurityContext 안의 Authentication으로 표현된다.

Authentication authentication = SecurityContextHolder
			.getContext()
            .getAuthentication();

Authentication에는 사용자와 권한 정보가 들어 있다.

JWT 필터를 직접 구현했다면 검증이 끝난 뒤 다음처럼 넣을 수도 있다.

SecurityContextHolder
        .getContext()
        .setAuthentication(authentication);

이 시점부터 뒤쪽 필터와 Controller는 현재 요청에 인증된 사용자가 있다고 본다.

Controller에서는 인자로 바로 받을 수도 있다.

@GetMapping("/me")
public String me(Authentication authentication) {
    return authentication.getName();
}

Servlet 기반 애플리케이션에서는 이 컨텍스트가 요청을 처리하는 실행 흐름과 연결돼 관리된다. 요청이 끝날 때 정리하는 것도 Spring Security 필터의 몫이다.

AuthenticationManager와 AuthenticationProvider

아이디와 비밀번호를 받아 로그인할 때는 먼저 아직 인증되지 않은 Authentication을 만든다.

UsernamePasswordAuthenticationToken token = 
	UsernamePasswordAuthenticationToken.unauthenticated(email, password);

그다음 AuthenticationManager에 넘긴다.

Authentication authentication = authenticationManager.authenticate(token);

AuthenticationManager가 모든 인증 방식을 직접 구현하는 것은 아니다. 전달받은 인증 객체를 처리할 수 있는 AuthenticationProvider에게 인증을 맡긴다.

아이디/비밀번호 인증에서는 보통 DaoAuthenticationProvider가 사용된다.

DaoAuthenticationProvider는 사용자 정보를 조회하고 저장된 비밀번호와 입력 비밀번호를 비교한 뒤, 성공하면 인증 완료된 Authentication을 돌려준다.

UserDetailsService는 조회만 한다

UserDetailsService라는 이름 때문에 이 클래스에서 로그인 로직을 전부 만들어야 할 것처럼 보이지만 역할은 사용자 조회다.

인터페이스도 단순하다.

UserDetails loadUserByUsername(String username);

예를 들면 다음과 같다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService
        implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {

        User user = userRepository
                .findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return User.withUsername(user.getEmail())
                .password(user.getPassword())
                .roles(user.getRole().name())
                .build();
    }
}

여기에서는 비밀번호를 비교하지 않는다. DB에서 사용자를 찾아 Spring Security가 사용할 UserDetails 형태로 돌려주는 데서 끝난다.

비밀번호는 누가 비교하나?

아이디/비밀번호 로그인의 기본 구성에서는 DaoAuthenticationProviderPasswordEncoder를 사용해 비교한다.

흐름을 연결하면 이렇다.

UsernamePasswordAuthenticationToken
        ↓
AuthenticationManager
        ↓
DaoAuthenticationProvider
        ├─ UserDetailsService
        └─ PasswordEncoder

UserDetailsService는 저장된 사용자를 가져오고, PasswordEncoder는 입력 비밀번호와 저장된 해시가 맞는지 검사한다.

그래서 Spring Security의 기본 인증 구조를 사용하는데 Controller나 Service에서 다시 passwordEncoder.matches()를 직접 호출한다면 역할이 겹칠 가능성이 크다.

누가 조회하고, 누가 검증하고, 누가 현재 인증 상태를 들고 있는지만 구분해도 Spring Security 구조가 훨씬 덜 복잡해진다.


04장 세션 로그인

세션 로그인에서는 로그인 상태를 서버가 보관한다.

사용자가 로그인하면 서버가 세션을 만들고, 브라우저에는 그 세션을 찾을 수 있는 식별자를 준다.

서버가 기억한다

로그인에 성공한 뒤 서버에 다음과 같은 상태가 있다고 하자.

session: 8A7F...
user: 10
role: USER

응답에는 세션 ID가 쿠키로 내려간다.

Set-Cookie: JSESSIONID=8A7F...

이후 브라우저는 같은 조건의 요청에 쿠키를 자동으로 붙인다.

GET /my-page
Cookie: JSESSIONID=8A7F...

서버는 JSESSIONID로 세션을 찾고 기존 인증 정보를 복원한다.

클라이언트가 사용자 정보 전체를 들고 있는 것이 아니다. 세션을 찾을 열쇠만 가지고 있고 실제 로그인 상태는 서버에 있다.

formLogin이 대신 해주는 일

Spring Security의 formLogin()을 사용하면 아이디/비밀번호 로그인에 필요한 기본 흐름이 이미 준비돼 있다.

http.formLogin(Customizer.withDefaults());

코드는 짧지만 내부에서는 여러 단계가 이어진다.

UsernamePasswordAuthenticationFilter가 로그인 요청에서 아이디와 비밀번호를 읽고 AuthenticationManager에 인증을 요청한다. Provider가 비밀번호를 확인하고 인증에 성공하면 Authentication이 만들어진다.

세션을 사용하는 설정이라면 인증 결과를 다음 요청에서도 꺼낼 수 있도록 SecurityContext가 세션과 연결된다.

그래서 두 번째 요청에서는 비밀번호를 다시 입력하지 않아도 된다.

로그아웃과 세션 무효화

세션 방식의 로그아웃은 비교적 직관적이다.

서버가 로그인 상태를 가지고 있으니 그 상태를 지우면 된다.

http.logout(logout -> logout
    .logoutUrl("/logout")
    .invalidateHttpSession(true)
    .clearAuthentication(true)
    .deleteCookies("JSESSIONID")
);

세션이 무효화된 뒤에는 기존 JSESSIONID를 다시 보내도 정상적인 인증 상태를 찾을 수 없다.

이 특성은 뒤에서 볼 self-contained Access Token과 꽤 다르다. 서버에 개별 토큰 상태가 없으면 클라이언트가 토큰을 버렸다는 사실만으로 그 토큰 자체가 즉시 쓸모없어지지는 않는다.

세션을 Redis로 옮기기

애플리케이션 서버 한 대의 메모리에 세션을 두면 구성이 단순하다.

서버가 여러 대가 되면 문제가 생긴다.

로그인은 A 서버에서 했는데 다음 요청이 B 서버로 가면 B 서버의 로컬 메모리에는 해당 세션이 없을 수 있다.

이때 Redis처럼 모든 애플리케이션 인스턴스가 접근할 수 있는 저장소에 세션을 둘 수 있다.

Spring에서는 Spring Session과 Redis를 함께 쓰는 구성이 흔하다.

애플리케이션 서버는 어느 인스턴스로 요청이 들어와도 Redis에서 같은 세션을 찾는다.

다만 세션을 Redis에 저장한다고 해서 stateless가 되는 것은 아니다. 인증 상태는 여전히 서버 측 저장소에 있고, 위치만 애플리케이션 메모리에서 Redis로 바뀐 것이다.


05장 세션의 한계

세션은 오래된 방식이라서 나쁜 것이 아니다. 브라우저 중심 서비스에서는 지금도 구현과 통제가 편한 선택이다.

문제는 애플리케이션이 여러 대로 늘어나면서 서버 로컬 상태를 어떻게 다룰 것인지다.

서버가 여러 대가 되는 순간

로드 밸런서 뒤에 서버 두 대가 있다고 하자.

         ┌─ Server A
Client ─ LB
         └─ Server B

세션이 각 서버 메모리에 따로 있다면 로그인 요청을 처리한 서버가 아닌 다른 서버로 다음 요청이 갔을 때 세션을 찾지 못한다.

해결책은 여러 가지다.

특정 사용자의 요청을 한 서버로 고정할 수도 있고, 세션을 외부 저장소로 옮길 수도 있다. 서버 사이에 세션을 복제하는 방식도 있다.

방법마다 비용이 다르다.

Sticky Session은 무엇을 포기하는가

Sticky Session은 같은 사용자의 요청을 가능한 한 같은 서버로 보내는 방식이다.

구성하기 쉽지만 로드 밸런서가 요청을 자유롭게 분산하지 못하게 된다.

Server A에 붙은 사용자가 많고 Server B에 붙은 사용자가 적더라도 기존 연결을 유지하려면 A에 트래픽이 몰릴 수 있다.

서버 로컬 메모리에만 세션이 있다면 A가 장애로 내려갔을 때 해당 세션도 함께 잃는다.

Sticky Session은 충분히 쓸 수 있는 방법이지만 "서버를 아무 인스턴스로나 자유롭게 바꿔도 된다"는 성질은 약해진다.

매 요청 저장소 조회 비용?

세션을 Redis에 둔다면 인증이 필요한 요청에서 세션을 읽는 네트워크 작업이 생긴다.

Redis가 빠르다고 해도 프로세스 내부 메모리 조회와 같지는 않다. 요청량이 많아지면 세션 저장소도 시스템에서 중요한 의존성이 된다.

그렇다고 곧바로 "세션은 느리고 JWT는 빠르다"고 결론 내리면 곤란하다.

JWT도 매 요청마다 서명을 검증한다. 권한 변경을 실시간으로 반영하려고 DB나 Redis를 다시 확인하면 저장소 접근도 생긴다. 블랙리스트를 도입해도 마찬가지다.

세션과 JWT를 고를 때는 단일 요청의 연산량보다 로그인 상태를 중앙에서 제어해야 하는지, 즉시 로그아웃이 필요한지, 여러 서비스가 같은 인증 정보를 검증해야 하는지를 함께 보는 편이 낫다.


06장 JWT 이해하기

JWT는 세션을 없애기 위해 꼭 써야 하는 기술이 아니다.

서명된 토큰 안에 필요한 claim을 담아두면 서버가 별도의 세션을 읽지 않고도 요청자의 신원을 확인할 수 있다. 이 성질 때문에 stateless API 인증에서 자주 쓰인다.

서버가 기억하지 않는다

로그인에 성공하면 서버가 Access Token을 발급한다.

클라이언트는 이후 API 요청에 그 토큰을 담는다.

Authorization: Bearer eyJ...

서버는 토큰의 서명과 만료 시간 등을 검사하고, 안에 든 sub나 권한 claim으로 Authentication을 구성할 수 있다.

Access Token마다 별도 서버 세션을 저장하지 않는 구성이라면 요청 하나만 보고 인증을 끝낼 수 있다.

단, JWT를 쓴다고 시스템 전체가 자동으로 stateless가 되는 것은 아니다. Refresh Token 저장소나 blacklist를 추가하면 그 부분에서는 다시 상태를 관리하게 된다.

Header, Payload, Signature

JWT는 점으로 구분된 세 부분을 가진다.

Header.Payload.Signature

Header에는 토큰 타입과 서명 알고리즘 같은 정보가 들어간다.

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload에는 claim을 넣는다.

{
  "sub": "10",
  "role": "USER",
  "iat": 1700000000,
  "exp": 1700000900
}

마지막 Signature는 Header와 Payload가 발급 이후 바뀌지 않았는지 검증할 때 사용한다.

Base64는 암호화가 아니다

JWT 문자열을 처음 보면 내용을 알아보기 어려워서 암호화된 것처럼 느껴진다.

하지만 일반적인 JWT의 Header와 Payload는 Base64URL로 인코딩했을 뿐이다. 누구나 디코딩할 수 있다.

따라서 다음과 같은 값을 Payload에 넣으면 안 된다.

  • 비밀번호
  • 주민등록번호
  • 카드번호
  • 외부에 노출되면 곤란한 개인정보

JWT 서명은 Payload를 숨겨주지 않는다. 서명된 이후 누군가 내용을 몰래 바꾸지 못하도록 검증하는 데 쓰인다.

내용을 감춰야 한다면 서명 JWT와는 별개의 기밀성 문제를 해결해야 한다.

서명이 위조를 막는 방식

정상 토큰의 Payload에 이런 값이 있다고 하자.

{
  "sub": "10",
  "role": "USER"
}

공격자가 USERADMIN으로 바꾸는 것 자체는 어렵지 않다. Base64URL 인코딩은 비밀 기술이 아니기 때문이다.

하지만 Payload가 바뀌는 순간 기존 Signature는 더 이상 맞지 않는다.

서버가 자신이 신뢰하는 키로 서명을 검증하면 변조 사실을 발견한다.

공격자가 수정한 Payload에 맞는 정상 Signature까지 만들려면 서명에 필요한 키를 가지고 있어야 한다.

그래서 JWT 보안에서는 알고리즘 선택만큼 키 관리가 중요하다.

내용은 다 보인다

Payload를 누구나 읽을 수 있다는 점 때문에 claim은 필요한 만큼만 넣는 편이 낫다.

예를 들면 사용자 ID, 권한, 발급 시각, 만료 시각 정도다.

{
  "sub": "123",
  "role": "USER",
  "iat": 1700000000,
  "exp": 1700000900
}

토큰이 커질수록 요청마다 전송하는 HTTP 헤더도 커진다. 사용자 프로필 전체를 JWT에 복사해 넣을 이유는 거의 없다.

또 Payload에 들어간 정보는 토큰이 만료될 때까지 오래된 값으로 남을 수 있다. DB에서 권한을 바꿨다고 기존 토큰의 role claim이 저절로 바뀌지는 않는다.

HS256과 RS256, 무엇을 고를까

HS256은 같은 secret으로 서명하고 검증한다.

토큰을 발급하는 서버와 검증하는 서버가 하나라면 구성이 간단하다.

문제는 여러 서비스가 JWT를 검증해야 할 때다. 검증하려는 모든 서비스에 같은 secret을 줘야 한다. 그 secret을 가진 서비스는 검증뿐 아니라 새로운 유효 토큰을 만드는 것도 가능하다.

RS256에서는 역할이 나뉜다.

인증 서버는 Private Key로 서명하고 다른 서비스는 Public Key로 검증한다.

Auth Server
Private Key
    ↓
   JWT

Service A ─ Public Key
Service B ─ Public Key

Service A가 Public Key를 가지고 있어도 새 토큰을 서명할 수는 없다.

그래서 여러 서비스가 하나의 발급자를 신뢰하는 구조에서는 비대칭키 방식이 관리하기 편한 경우가 많다.

HS256이 무조건 나쁘고 RS256이 무조건 좋은 것은 아니다. 토큰을 누가 발급해야 하고 어떤 서비스까지 키를 배포해야 하는지를 먼저 보면 선택이 쉬워진다.


07장 JWT 로그인 구현

이제 Spring Security 요청 흐름에 JWT를 넣어보자.

로그인 자체는 여전히 AuthenticationManager를 이용할 수 있다. 달라지는 부분은 인증 성공 이후다. 세션에 인증 결과를 저장하는 대신 토큰을 발급한다.

API 요청에서는 반대로 토큰을 읽어 Authentication을 복원한다.

비밀키는 어디에?

JWT secret이나 Private Key를 Java 코드에 직접 적어두는 것은 피해야 한다.

private static final String SECRET =
        "my-super-secret-key";

저 코드가 Git에 올라가는 순간 키를 교체해야 할 이유가 생긴다.

운영 환경에서는 환경 변수, Kubernetes Secret, Vault, 클라우드의 Secret Manager나 KMS 같은 외부 관리 수단을 사용할 수 있다.

설정 파일에는 참조만 둔다.

jwt:
  secret: ${JWT_SECRET}

HS256 같은 대칭키 알고리즘을 쓴다면 사람이 외우기 쉬운 문자열 대신 충분한 길이와 엔트로피를 가진 값을 사용해야 한다.

jwt-secret, 프로젝트 이름, 간단한 문장은 secret으로 적합하지 않다.

TokenProvider가 발급하고 검증한다

JWT 라이브러리 호출이 Controller와 Filter 여기저기에 흩어지기 시작하면 관리하기 어렵다.

토큰 생성과 검증을 담당하는 클래스를 하나 두는 편이 낫다.

@Component
public class TokenProvider {

    public String createAccessToken(
            Long userId,
            Collection<String> roles) {
        // JWT 생성
        return "...";
    }

    public boolean validateToken(String token) {
        // 서명, 만료 시간 등 검증
        return true;
    }

    public Long getUserId(String token) {
        // sub claim 읽기
        return 1L;
    }
}

실제 검증에서는 서명과 만료 시간만 볼 수도 있고, 서비스 정책에 따라 issuer나 audience도 확인할 수 있다.

단순히 문자열 파싱에 성공했다고 해서 신뢰할 수 있는 토큰이 되는 것은 아니다.

JwtAuthenticationFilter 붙이기

Access Token은 보통 Authorization 헤더로 보낸다.

Authorization: Bearer eyJ...

필터에서는 헤더를 읽고 Bearer Token을 추출한 뒤 검증한다.

토큰이 정상이라면 Authentication을 만들어 현재 SecurityContext에 넣는다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter
        extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain)
            throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && tokenProvider.validateToken(token)) {
            Authentication authentication =
                    tokenProvider.getAuthentication(token);

            SecurityContextHolder
                    .getContext()
                    .setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

Security Filter Chain에도 위치를 지정한다.

http.addFilterBefore(
    jwtAuthenticationFilter,
    UsernamePasswordAuthenticationFilter.class
);

이후의 인가 로직은 세션 인증이든 JWT 인증이든 SecurityContext 안의 Authentication을 기준으로 동작한다.

필터에서 예외를 던지지 않는 이유

JWT 파싱 중 예외가 났다고 다음처럼 바로 던지는 코드를 자주 보게 된다.

if (!validate(token)) {
    throw new InvalidTokenException();
}

여기서 조심할 점은 예외가 발생하는 위치다.

@RestControllerAdvice는 Spring MVC 요청 처리 과정의 예외를 다루기 편하지만 Servlet Filter에서 Controller에 도달하기 전에 발생한 예외까지 자동으로 처리해주지는 않는다.

그래서 JWT Filter의 실패 정책을 먼저 정해두는 편이 좋다.

한 가지 방법은 잘못된 토큰에서 Authentication을 만들지 않고 필터 체인을 계속 진행하는 것이다. 보호된 API라면 뒤에서 미인증 요청으로 판정돼 AuthenticationEntryPoint가 401을 만든다.

토큰 파싱 오류를 필터 안에서 직접 응답하거나 AuthenticationEntryPoint를 호출하도록 구성할 수도 있다.

어느 쪽이든 "예외를 던지면 누군가 알아서 JSON으로 바꿔주겠지"라고 두면 디버깅할 일이 생긴다.

401과 403 응답 핸들러

REST API라면 인증 실패 응답도 HTML보다 JSON이 편하다.

401은 AuthenticationEntryPoint에서 처리할 수 있다.

@Component
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException)
            throws IOException {

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("""
            {"code":"UNAUTHORIZED"}
            """);
    }
}

403은 AccessDeniedHandler로 처리한다.

@Component
public class CustomAccessDeniedHandler
        implements AccessDeniedHandler {

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException)
            throws IOException {

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.getWriter().write("""
            {"code":"FORBIDDEN"}
            """);
    }
}

두 핸들러를 Security 설정에 연결한다.

http.exceptionHandling(exception -> exception
    .authenticationEntryPoint(authenticationEntryPoint)
    .accessDeniedHandler(accessDeniedHandler)
);

SecurityConfig: STATELESS, CSRF, CORS

Access Token을 세션에 저장하지 않는 API라면 보통 세션 생성 정책을 STATELESS로 둔다.

http.sessionManagement(session -> session
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

공개 API와 인증이 필요한 API도 나눈다.

http.authorizeHttpRequests(auth -> auth
    .requestMatchers(
        "/auth/login",
        "/auth/signup",
        "/auth/refresh"
    ).permitAll()
    .anyRequest().authenticated()
);

CSRF는 JWT를 쓴다는 이유만으로 무조건 꺼야 하는 옵션은 아니다.

Access Token을 Authorization 헤더로만 보내고 브라우저가 인증 정보를 자동으로 붙이지 않는 구조라면 전통적인 쿠키 인증보다 CSRF 공격면이 작다. 그래서 이런 API에서는 CSRF를 비활성화하는 경우가 많다.

http.csrf(csrf -> csrf.disable());

반대로 Refresh Token을 쿠키에 넣으면 브라우저가 그 쿠키를 자동으로 보낸다. Refresh API 쪽에서는 다시 CSRF 조건을 따져야 한다.

CORS도 별개다. 프런트엔드와 API의 Origin이 다르다면 브라우저가 어떤 Origin의 JavaScript에 응답 접근을 허용할지 설정해야 한다.

인증, CSRF, CORS는 한 덩어리 설정처럼 보이지만 해결하는 문제가 서로 다르다.


08장 토큰은 취소할 수 없다

정확히 말하면, 서버가 개별 Access Token의 상태를 전혀 저장하지 않는 self-contained 구조에서는 이미 발급한 토큰 하나만 골라 즉시 취소하기 어렵다.

그 토큰은 서명이 정상이고 만료 시간이 남아 있는 동안 계속 유효하다.

로그아웃해도 토큰은 살아 있다

Access Token을 30분짜리로 발급했다고 하자.

13시에 발급한 토큰은 13시 30분까지 사용할 수 있다.

사용자가 13시 5분에 로그아웃하면서 브라우저에서 Access Token을 지웠다고 해도 서버 입장에서 그 토큰의 암호학적 상태는 달라지지 않는다.

13시 4분에 공격자가 이미 토큰을 복사해갔다면 13시 10분에도 사용할 수 있다.

세션 로그아웃처럼 서버 저장소에서 해당 인증 상태 한 건을 지우는 동작이 기본적으로 존재하지 않기 때문이다.

만료를 줄이면 UX가 불편하다

탈취한 Access Token의 사용 시간을 줄이는 가장 단순한 방법은 만료 시간을 짧게 잡는 것이다.

30분 대신 10분, 10분 대신 5분으로 줄이면 토큰 하나가 탈취됐을 때 살아 있는 시간도 줄어든다.

하지만 5분마다 사용자가 다시 로그인하게 만들 수는 없다.

짧은 Access Token과 별도의 Refresh Token을 함께 쓰는 이유가 여기에 있다.

Access Token과 Refresh Token

Access Token은 일반 API를 호출하는 데 사용하고 짧게 유지한다.

Refresh Token은 새 Access Token을 받는 데만 사용하며 더 길게 유지한다.

예를 들어 다음처럼 잡을 수 있다.

Access Token  : 15분
Refresh Token : 14일

일반 요청에는 Access Token만 보낸다.

GET /users/me
Authorization: Bearer <access-token>

Access Token이 만료되면 Refresh Token으로 /auth/refresh를 호출해 새 Access Token을 받는다.

사용자는 15분마다 로그인하지 않아도 되고, Access Token이 탈취됐을 때 노출되는 시간은 Refresh Token보다 훨씬 짧다.

Refresh Token만 서버에 저장하는 이유

Access Token도 전부 Redis에 저장하고 요청마다 조회할 수 있다.

그렇게 하면 개별 Access Token을 쉽게 제어할 수 있지만, JWT 하나만 검증하고 끝내는 구조와는 멀어진다.

그래서 Access Token은 짧게 발급하고 서버에 저장하지 않는 대신, 수명이 긴 Refresh Token만 서버에서 관리하는 설계를 많이 사용한다.

Refresh 요청은 모든 API 요청보다 발생 빈도가 낮다. 저장소 조회를 갱신 시점으로 제한하면서 장기 로그인 상태는 서버에서 끊을 수 있다.

Refresh Token 원문 대신 해시 값을 저장할 수도 있다. DB나 Redis의 내용이 유출됐을 때 저장된 문자열을 바로 Refresh Token으로 사용할 수 없게 만드는 방법이다.

재발급할 때 권한을 다시 읽는다

Access Token 안에 role=ADMIN이 들어 있는데 관리자가 사용자의 권한을 USER로 낮췄다고 해보자.

이미 발급한 Access Token 안의 claim은 바뀌지 않는다.

따라서 Access Token 수명이 남아 있는 동안 이전 권한이 계속 보일 수 있다.

Refresh 시점에는 DB에서 사용자를 다시 읽어 현재 권한으로 새 Access Token을 발급할 수 있다.

Refresh Token 검증
→ userId 확인
→ 현재 User 조회
→ 현재 권한으로 Access Token 발급

이렇게 해도 기존 Access Token의 남은 수명까지 즉시 없애는 것은 아니지만, 재발급 이후에는 오래된 권한을 이어받지 않는다.

Refresh Token Rotation

하나의 Refresh Token을 14일 내내 재사용하게 만들 수도 있다.

Rotation을 쓰면 갱신할 때마다 Refresh Token도 교체한다.

RT1 사용
→ RT1 폐기
→ RT2 발급

RT2 사용
→ RT2 폐기
→ RT3 발급

서버에는 현재 사용할 수 있는 Refresh Token 상태를 저장한다.

한 번 사용한 토큰이 계속 살아 있는 구조보다 탈취 사실을 탐지하기 쉬워진다.

폐기한 토큰이 돌아오면 탈취다

Rotation을 적용했는데 이미 RT2로 교체된 RT1이 다시 들어왔다고 하자.

정상적인 최신 클라이언트라면 RT1을 다시 보낼 이유가 없다.

네트워크 재시도나 동시 요청 같은 정상적인 경합 상황은 별도로 설계해야 하지만, 그 가능성을 제외하면 오래된 Refresh Token의 재사용은 복제나 탈취를 의심할 만한 신호다.

이때 같은 로그인 세션에 속한 Refresh Token 계열 전체를 폐기하는 정책을 사용할 수 있다.

정상 사용자도 다시 로그인해야 한다는 비용은 있지만, 공격자가 다음 Refresh Token을 계속 받아가는 상황은 막을 수 있다.

블랙리스트로 즉시 로그아웃 구현하기

Access Token까지 즉시 끊어야 한다면 blacklist를 둘 수 있다.

토큰에 고유 식별자인 jti를 넣는다.

{
  "sub": "10",
  "jti": "e94b..."
}

로그아웃할 때 해당 jti를 Redis에 저장하고 TTL은 Access Token의 남은 만료 시간만큼 준다.

API 요청에서는 JWT 자체를 검증한 뒤 blacklist에도 있는지 확인한다.

이제 로그아웃 직후 기존 Access Token을 막을 수 있다.

대신 모든 인증 요청에 Redis 조회가 하나 추가된다. 즉시 폐기 기능을 얻는 대신 self-contained 토큰이 갖던 단순한 요청 처리 구조 일부를 포기한 셈이다.

다중 기기 로그인을 위한 키 설계

Refresh Token을 사용자 ID 하나로만 저장하면 다중 기기 로그인이 불편하다.

refresh:user:10

PC에서 로그인한 뒤 모바일에서 다시 로그인하면서 같은 키를 덮어쓰면 PC 쪽 Refresh Token이 사라질 수 있다.

로그인 세션을 별도로 식별하면 해결하기 쉽다.

refresh:{userId}:{sessionId}

예를 들면 PC와 모바일에 서로 다른 sessionId를 발급한다.

refresh:10:session-a
refresh:10:session-b

그러면 PC만 로그아웃시키거나 사용자의 모든 세션을 한꺼번에 종료하는 기능을 나눠서 만들 수 있다.

sessionId는 클라이언트가 임의로 정한 문자열보다 로그인할 때 서버가 발급하고 관리하는 편이 안전하다.


09장 토큰을 어디에 둘 것인가

토큰 알고리즘을 잘 고르고 만료 시간을 정했다고 끝나는 것은 아니다.

브라우저 애플리케이션에서는 토큰을 어느 저장소에 둘지도 공격 방식에 영향을 준다.

특히 XSS와 CSRF는 성격이 달라서 한쪽을 막는 선택이 다른 쪽에 새로운 조건을 만들 수 있다.

Refresh Token을 localStorage에 두면 안 되는 이유

localStorage는 JavaScript가 읽을 수 있다.

localStorage.getItem("refreshToken");

개발하기는 편하지만 XSS가 발생하면 공격자의 스크립트도 같은 API를 호출할 수 있다.

Refresh Token은 Access Token보다 수명이 길다. 한 번 외부로 복사되면 오랫동안 새 Access Token을 만드는 데 악용될 수 있다.

그래서 브라우저 애플리케이션에서 장기 자격 증명인 Refresh Token을 localStorage에 넣는 설계는 피하는 편이 낫다.

HttpOnly 쿠키와 Secure, SameSite, Path

Refresh Token을 HttpOnly Cookie에 넣으면 브라우저의 JavaScript에서 값을 직접 읽을 수 없다.

Set-Cookie: refresh_token=...;
HttpOnly;
Secure;
SameSite=Lax;
Path=/auth/refresh

HttpOnlydocument.cookie 같은 JavaScript 접근을 막는다.

Secure는 HTTPS 연결에서만 쿠키를 보내도록 한다.

SameSite는 cross-site 요청에서 쿠키를 보낼 조건을 제한한다.

Path는 쿠키가 필요한 URL 범위를 줄인다. Refresh Token을 /auth/refresh에서만 쓴다면 다른 API 요청에 계속 붙일 필요가 없다.

각 속성이 막는 문제가 다르기 때문에 HttpOnly 하나만 붙였다고 쿠키 보안 설정이 끝난 것은 아니다.

쿠키의 함정, CSRF

쿠키는 브라우저가 조건에 맞으면 자동으로 보낸다.

여기서 CSRF 문제가 생긴다.

공격자가 Refresh Token 값을 직접 읽지 못하더라도 사용자의 브라우저가 우리 서버로 요청하도록 유도할 수 있다. 조건이 맞으면 인증 쿠키도 함께 전송될 수 있다.

그래서 쿠키를 사용한다면 SameSite 설정, CSRF Token, Origin 검사 같은 방어를 서비스 구조에 맞춰 검토해야 한다.

HttpOnly는 토큰 값을 JavaScript가 읽는 것을 막는 옵션이지 CSRF 방어 옵션은 아니다.

그럼에도 쿠키를 택하는 이유

HttpOnly Cookie를 쓰는 가장 큰 이유는 장기간 사용할 자격 증명을 JavaScript에서 떼어놓을 수 있기 때문이다.

XSS가 발생했다고 해서 모든 피해를 막는 것은 아니다. 공격 스크립트가 페이지 안에서 사용자의 권한으로 요청을 보낼 수는 있다.

그래도 Refresh Token 문자열을 읽어서 공격자 서버로 가져간 뒤 장기간 재사용하는 위험은 줄일 수 있다.

보안 설계에서는 공격 가능성을 0으로 만드는 경우보다, 탈취 가능한 자격 증명의 범위와 수명을 줄이는 경우가 더 많다.

Access Token은 메모리에 두고 헤더로 보낸다

브라우저 SPA에서는 Access Token을 JavaScript 메모리에만 두고 Refresh Token을 HttpOnly Cookie에 넣는 구성을 사용할 수 있다.

API 요청에서는 메모리의 Access Token을 Authorization 헤더에 직접 넣는다.

fetch("/api/me", {
  headers: {
    Authorization: `Bearer ${accessToken}`
  }
});

Authorization 헤더는 쿠키처럼 브라우저가 모든 요청에 자동으로 붙이는 값이 아니다.

페이지를 새로고침하면 메모리의 Access Token은 사라진다.

이때 /auth/refresh를 호출해 HttpOnly Cookie로 인증하고 새 Access Token을 받아 메모리를 복구할 수 있다.

이 방식이 모든 웹 서비스의 정답은 아니다. 서버 렌더링 애플리케이션이나 BFF 구조에서는 다른 선택이 더 단순할 수 있다.

Refresh Token을 바디에 담지 않는다

Refresh Token을 HttpOnly Cookie로 관리하기로 했다면 로그인 응답 JSON에 같은 토큰을 다시 넣을 이유가 없다.

다음처럼 응답하면 JavaScript가 Refresh Token을 볼 수 있다.

{
  "accessToken": "...",
  "refreshToken": "..."
}

HttpOnly로 분리한 의미가 약해진다.

대신 서버가 Refresh Token을 Cookie에 설정하고 응답 바디에는 Access Token만 보내는 식으로 구성할 수 있다.

Set-Cookie: refresh_token=...; HttpOnly; Secure
Content-Type: application/json

{
  "accessToken": "..."
}

Refresh 요청에서도 프런트엔드는 Refresh Token 문자열을 직접 꺼낼 필요가 없다.

네이티브 모바일 앱은 상황이 다르다. 브라우저의 HttpOnly Cookie 모델을 그대로 적용하기보다 OS가 제공하는 보안 저장소를 사용하는 편이 적합할 수 있다.


10장 OAuth 2.0 로그인

구글이나 GitHub 계정으로 로그인한다고 해서 우리 서비스가 그 사용자의 구글 비밀번호를 받는 것은 아니다.

사용자의 인증은 외부 Provider가 맡고, 우리 서비스는 그 결과를 받아 자체 회원과 연결한다.

비밀번호를 넘기지 않고 로그인하기

사용자가 Google 로그인을 누르면 브라우저를 Google의 인증 화면으로 보낸다.

사용자는 Google에 직접 로그인한다. 우리 서비스는 Google 비밀번호를 볼 일이 없다.

인증이 끝나면 Provider는 정해진 OAuth/OIDC 흐름을 따라 우리 서비스가 사용자를 식별할 수 있는 결과를 돌려준다.

외부 로그인에서 가장 중요한 경계 중 하나다. 외부 계정의 자격 증명은 외부 Provider에 맡기고, 우리 서비스는 Provider가 확인한 사용자 ID만 이용한다.

Authorization Code Grant

Authorization Code 방식에서는 Provider 인증이 끝난 뒤 브라우저가 바로 Access Token을 받지 않는다.

먼저 짧게 사용할 code가 callback으로 전달된다.

Browser
→ Provider 로그인
→ callback?code=abc

우리 서버는 받은 code로 Provider의 Token Endpoint를 호출한다.

Backend
→ code
→ Provider Token Endpoint
→ Provider Token

그 토큰을 이용해 사용자 정보를 조회하거나 OIDC에서 받은 정보를 검증한다.

여기서 Provider가 발급한 Access Token과 우리 서비스가 나중에 발급할 JWT Access Token은 다른 자격 증명이다.

Google Access Token은 Google API를 호출할 때 쓰고, 우리 Access Token은 우리 API를 호출할 때 쓴다.

code를 토큰으로 한 번 더 바꾸는 이유

브라우저 리다이렉트 URL에 중요한 토큰을 바로 싣는 것보다 짧은 수명의 Authorization Code를 거치는 편이 노출 범위를 줄일 수 있다.

URL은 브라우저 기록, 웹 서버나 프록시 로그, 분석 도구 등 생각보다 많은 곳에 남을 수 있다.

Authorization Code는 back-channel의 Token Endpoint에서 실제 토큰으로 교환한다.

현대 OAuth 클라이언트에서는 PKCE도 함께 쓰는 경우가 많다.

PKCE를 적용하면 Authorization Code가 중간에 탈취되더라도 처음 인증 요청을 시작할 때 만든 verifier 없이는 code를 사용하기 어렵게 만들 수 있다.

state 파라미터와 CSRF 방어

OAuth 로그인 요청을 시작할 때 임의의 state를 만들고 callback에서 같은 값이 돌아왔는지 확인할 수 있다.

/login/oauth
state=random-value

      ↓

/callback
code=...
state=random-value

값이 다르면 우리 애플리케이션이 시작한 인증 흐름이 아닐 가능성이 있으므로 요청을 거부한다.

state는 OAuth 인증 요청과 callback을 연결하고 login CSRF 같은 공격을 막는 데 사용된다.

프레임워크가 이 값을 관리해주더라도 왜 필요한 값인지는 알고 있는 편이 좋다.

OAuth는 인가, OIDC는 인증

OAuth 2.0은 원래 다른 서비스의 리소스에 접근할 권한을 위임하기 위한 프로토콜이다.

예를 들어 "이 애플리케이션이 내 Google Calendar를 읽게 허용하겠다" 같은 문제를 다룬다.

OpenID Connect는 OAuth 2.0 위에 사용자 인증을 위한 계층을 추가한다. OIDC에서는 ID Token을 이용해 사용자의 신원을 표현한다.

그래서 일상적으로 "OAuth 로그인"이라고 부르는 기능 중에는 실제로 OIDC를 사용하는 경우가 많다.

일부 Provider는 OAuth 2.0 Access Token으로 별도의 사용자 정보 API를 호출해 로그인 기능을 제공하기도 한다. 서비스마다 구현 방식은 조금씩 다르다.

CustomOAuth2UserService가 조회하고 가입시킨다

Spring Security OAuth2 Client를 사용하면 Provider와의 기본 로그인 흐름을 상당 부분 맡길 수 있다.

Provider에서 사용자 정보를 받아온 뒤에는 그 외부 사용자를 우리 서비스의 User와 연결해야 한다.

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService
        extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(
            OAuth2UserRequest userRequest) {

        OAuth2User oauthUser =
                super.loadUser(userRequest);

        String registrationId =
                userRequest
                    .getClientRegistration()
                    .getRegistrationId();

        Map<String, Object> attributes =
                oauthUser.getAttributes();

        // provider별 사용자 ID 추출
        // 기존 OAuth 계정 조회
        // 필요하면 회원 생성

        return oauthUser;
    }
}

Google, Kakao, Naver처럼 Provider마다 사용자 정보 구조가 다르기 때문에 registrationId를 보고 필요한 값을 추출하는 코드가 들어갈 수 있다.

OIDC Provider라면 OidcUserService 계열을 쓰는 구조도 고려한다.

사용자 식별은 providerId로

외부 계정을 이메일만 가지고 식별하면 위험한 경우가 있다.

Google에서 받은 hello@example.com과 다른 Provider가 돌려준 같은 이메일 주소가 항상 같은 사람을 뜻한다고 보장할 수는 없다.

이메일은 변경될 수도 있고 Provider마다 이메일 검증 정책도 다르다.

외부 계정 연결에는 Provider가 보장하는 고유 사용자 ID를 쓰는 편이 안전하다.

provider   = GOOGLE
providerId = 109283746192837

DB에는 (provider, providerId) 조합을 유니크하게 둘 수 있다.

OIDC에서는 issuer와 sub가 사용자 식별에서 중요한 값이다.

이메일은 로그인 식별자의 절대 기준보다 사용자 프로필 속성으로 보는 편이 설계가 덜 꼬인다.

SuccessHandler에서 우리 서비스 JWT 발급하기

외부 Provider 인증이 끝났다고 해서 그 Provider의 토큰을 우리 API 인증에 그대로 쓸 필요는 없다.

Spring Security의 OAuth 로그인에 성공한 뒤 AuthenticationSuccessHandler에서 우리 서비스용 인증 정보를 발급할 수 있다.

@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler
        extends SimpleUrlAuthenticationSuccessHandler {

    private final TokenProvider tokenProvider;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication)
            throws IOException {

        String accessToken =
                tokenProvider.createAccessToken(...);

        // Refresh Token 발급
        // 서버 저장
        // HttpOnly Cookie 설정
        // frontend redirect
    }
}

이 구조에서는 Provider가 담당하는 로그인과 우리 서비스의 로그인 상태가 분리된다.

Provider는 "이 외부 계정의 사용자가 맞다"는 부분을 담당하고, 이후 우리 API에서 어떤 토큰을 쓰고 언제 만료시킬지는 우리 서비스가 결정한다.

리다이렉트로 토큰을 넘길 때의 위험

OAuth 로그인이 끝난 뒤 프런트엔드로 돌아갈 때 이런 URL을 만들고 싶을 수 있다.

https://frontend.example.com/oauth/callback
?accessToken=...
&refreshToken=...

구현은 쉽지만 토큰이 URL에 그대로 남는다.

브라우저 기록이나 서버 로그, 프록시, 분석 도구 등 URL을 수집하는 곳에 자격 증명이 섞일 수 있다. 특히 수명이 긴 Refresh Token은 URL로 전달하지 않는 편이 낫다.

Refresh Token은 서버가 HttpOnly Cookie로 내려주고, Access Token은 별도의 교환 과정을 두는 방법이 있다.

예를 들면 로그인 성공 후 일회성 code만 프런트엔드에 넘긴다.

OAuth 로그인 성공
→ one-time code 생성
→ /callback?code=xyz

프런트엔드는 그 code를 서버에 다시 제출한다.

POST /auth/exchange

서버는 code를 한 번 사용한 뒤 폐기하고 Access Token을 응답한다.

다른 방법으로는 로그인 성공 시 Refresh Cookie만 설정한 뒤 프런트엔드로 이동시키고, 프런트엔드가 /auth/refresh를 호출해 Access Token을 받도록 만들 수도 있다.

어떤 방식을 쓰든 URL에는 오래 살아 있는 자격 증명을 직접 싣지 않는 편이 안전하다.

0개의 댓글