Series/내가 해본

Spring Security 로 회원 가입을 구현해보자 (2)

Hyunec 2021. 6. 19. 18:44

Step 3. JWT 적용

1. 준비

// build.gradle

dependencies {
  implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
  runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
  runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
}
// application.yml
application:
  jwt:
    secreat: ${SECREAT:341242FF3617E8D58D7A4F6FEED56FEB8619CF82B9B00829A02E161A4418BFA2}

본 예제에서는 jwt 생성시 HS256 알고리즘을 사용합니다.
HS256 알고리즘을 위해서는 secreat 이 256 bit 이상이어야하는데, 이를 위해 'TEST_KEY' 문자열을 SHA256 으로 해시합니다.

@Configuration
public class JwtConfiguration {

  @Value("${application.jwt.secreat}")
  private String secreat;

  @Bean
  public JwtAuthTokenProvider jwtAuthTokenProvider() {
    return new JwtAuthTokenProvider(secreat);
  }
}

secreat 을 환경 변수에서 가져오고, 다음 step 에서 만들 provider 를 bean 으로 등록합니다.

public enum Role {
  ADMIN("ROLE_ADMIN", "관리자 권한"),
  MEMBER("ROLE_MEMBER", "회원 권한"),
  UNKNOWN("UNKNOWN", "알 수 없는 권한");

  private final String code;
  private final String description;
}


@Entity
public class Member {

  private String role;

  public Member(String email, String password) {
    this.email = email;
    this.password = password;
    this.role = Role.MEMBER.getCode();
  }
}

Member entity 에 role 을 추가하고, 생성자에 Member 권한을 부여하는 로직을 추가합니다.

예외 처리는 최소화해서 구현했기에 생략합니다. repository 를 참고해주세요.

2. JwtAuthToken & Provider 구현

public class JwtAuthToken implements AuthToken<Claims> {

  private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

  @Getter
  private final String token;
  private final Key secreatKey;

  JwtAuthToken(String token, Key secreatKey) {
    this.token = token;
    this.secreatKey = secreatKey;
  }

  JwtAuthToken(String email, String role, Date expiredDate, Key secreatKey) {
    Map<String, Object> claims = new HashMap<>() {
      {
        put("email", email);
        put("role", role);
      }
    };
    this.token = Jwts.builder()
        .setClaims(claims)
        .signWith(secreatKey, signatureAlgorithm)
        .setExpiration(expiredDate)
        .compact();
    this.secreatKey = secreatKey;
  }

  @Override
  public boolean validate() {
    return getClaims() != null;
  }

  @Override
  public Claims getClaims() {
    try {
      return Jwts.parserBuilder().setSigningKey(secreatKey).build().parseClaimsJws(token).getBody();
    } catch (... exception) {
      ... 예외 처리
    }

    return null;
  }
}
  • token 종류가 늘어날 것을 고려해 AuthToken interface 를 활용합니다.
  • JwtAuthToken 은 token string 과 secreat 를 관리하는 객체입니다.
    • 신규 jwt 생성시 claim 에 이메일 주소와 권한을 넣습니다.
  • claims 의 예외 처리는 구현하지 못했습니다. (마지막에 설명합니다.)
public class JwtAuthTokenProvider implements AuthTokenProvider<JwtAuthToken> {

  private static final String AUTHORITIES_KEY = "role";
  private final Key secreat;

  public JwtAuthTokenProvider(String secreat) {
    byte[] keyBytes = Decoders.BASE64.decode(secreat);
    this.secreat = Keys.hmacShaKeyFor(keyBytes);
  }

  @Override
  public JwtAuthToken createAuthToken(String email, String role, Date expiredDate) {
    return new JwtAuthToken(email, role, expiredDate, secreat);
  }

  @Override
  public JwtAuthToken convertAuthToken(String token) {
    return new JwtAuthToken(token, secreat);
  }

  @Override
  public Authentication getAuthentication(JwtAuthToken authToken) {
    if (authToken.validate()) {
      Claims claims = authToken.getClaims();
      Collection<? extends GrantedAuthority> authorities = Collections.singleton(
          new SimpleGrantedAuthority(claims.get(AUTHORITIES_KEY).toString()));
      User principal = new User(claims.get("email").toString(), "", authorities);

      return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
    } else {
      throw new CustomAuthenticationException(ErrorCode.FAILED_GENERATE_TOKEN);
    }
  }
}
  • token 종류가 늘어날 것을 고려해 AuthTokenProvider interface 를 활용합니다.
  • JwtAuthToken 을 만드는 역할과 Authentication 을 가져오는 역할을 합니다.

3. 로그인을 구현합니다

@RestController
public class LoginController {

  private final LoginService loginService;

  @PostMapping("/api/v1/login")
  public String login(@RequestBody @Valid LoginMemberRequest request) {
    JwtAuthToken jwtAuthToken = loginService.login(request.getLoginId(), request.getPassword());

    return jwtAuthToken.getToken();
  }
}


@Service
public class LoginService {

  private final static long LOGIN_RETENTION_MINUTES = 300;

  private final AuthenticationManagerBuilder authenticationManagerBuilder;
  private final JwtAuthTokenProvider jwtAuthTokenProvider;
  private final PasswordEncoder passwordEncoder;

  private final MemberRepository memberRepository;

  public JwtAuthToken login(String loginId, String password) {
    Member member = memberRepository.findByEmail(loginId)
        .orElseThrow(() -> new CustomAuthenticationException(NOT_EXIST_MEMBER));

    if (!passwordEncoder.matches(password, member.getPassword())) {
      throw new CustomAuthenticationException(NOT_VALID_PASSWORD);
    }

    // 중요! SecurityContextHolder
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getEmail(), password);
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    SecurityContextHolder.getContext().setAuthentication(authentication);

    Role role = authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .findFirst()
        .map(Role::of)
        .orElseThrow(() -> new CustomAuthrizationException(NOT_EXIST_AUTHORIZATION));
    Date expiredDate = Date.from(
        LocalDateTime.now().plusMinutes(LOGIN_RETENTION_MINUTES).atZone(ZoneId.systemDefault()).toInstant());

    return jwtAuthTokenProvider.createAuthToken(member.getEmail(), role.getCode(), expiredDate);
  }
}


@Service
public class CustomUserDetailsService implements UserDetailsService {

  private final MemberRepository memberRepository;

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    return memberRepository.findByEmail(email)
        .map(this::createSpringSecurityUser)
        .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.NOT_EXIST_MEMBER));
  }

  private User createSpringSecurityUser(Member member) {
    List<GrantedAuthority> grantedAuthorities = Collections.singletonList(new SimpleGrantedAuthority(member.getRole()));
    return new User(member.getEmail(), member.getPassword(), grantedAuthorities);
  }
}

로그인 과정에서 SecurityContextHolder 에 인증 정보를 등록하고, jwt 를 반환합니다.

3-1. SecurityContextHolder 흐름

LoginService.login() 에서는 고작 3줄로 표현되었지만, 이 흐름이 security 에서 제공하는 마법같은 인증 기능입니다.
아래는 그 중 일부를 설명한 것으로 디버그 모드를 통해 흐름을 따라가보시는 것을 추천합니다.

@Service
public class LoginService {

  // 1. UsernamePasswordAuthenticationToken 를 가져오기 위해 DaoAuthenticationProvider.retrieveUser() 가 호출됩니다.
  UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getEmail(), password);
  Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

  // 4. 인증된 Authentication 를 SecurityContextHolder 에 등록합니다.
  SecurityContextHolder.getContext().setAuthentication(authentication);
}


public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider{

  // 0. BCrypt 로 정의한 PasswordEncoder 가 적용됩니다.
  public DaoAuthenticationProvider() {
    setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
  }

  @Override
  protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
    throws AuthenticationException {
    // 2. loadUserByUsername() 를 CustomUserDetailsService 로 Override 하였기에 우리가 찾는 회원의 UserDetails 를 가져올 수 있습니다.
    UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
  }

  @Override
 protected void additionalAuthenticationChecks(UserDetails userDetails,
   UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

  // 3. 비밀번호를 비교하는 부분으로 passwordEncoder 가 적용됩니다.
  String presentedPassword = authentication.getCredentials().toString();
  if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
   throw new BadCredentialsException(this.messages
     .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
  }
 }
}


@Service
public class CustomUserDetailsService implements UserDetailsService {

  // 2-1. UserDetails 을 가져오는 로직을 Override 합니다.
  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    ... UserDetails 가져오기
  }
}

4. 로그인 테스트

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class LoginControllerTest {

  @DisplayName("[web] 로그인")
  @TestFactory
  public Stream<DynamicTest> login() {
    String email = "hyune@gmail.com";
    String password = "1q2w3e4r*";

    return Stream.of(
        dynamicTest("[성공] 회원 가입", () -> {
          ... 회원 가입
        }),
        dynamicTest("[성공] 로그인", () -> {
          ... 로그인
        })
    );
  }
}

로그인에 성공하면 정상적인 jwt 가 반환됨을 확인할 수 있습니다.

로그인 정보 가져오기는 다음으로 이어집니다.