Spring Security를 이용해 로그인 기능 구현방법에 대한 내용이다. 로그인 기능을 위해서만을 위해 스프링 시큐리티를 적용하는 것이 애플리케이션이 무거워질 수 있다고는 하지만 일단 한번 경험해보면 좋을 것 같아서 스프링 시큐리티를 적용해보았다.
- Spring Security란?
스프링 시큐리티는 스프링 생태계에서 사용자 인증/권한 관리/보안 등의 기능을 효율적이고 신속하게 구현할 수 있게 도움을 주는 스프링 프레임워크 중 하나이다. 다양한 인증방식 중에 principal(username)-credential(password) 패턴을 사용하고 있다.
Spring Security 특징
Filter를 기반으로 동작
Spring MVC와 분리되어 관리하고 동작할 수 있다.
Bean으로 설정가능
Spring Security 3.2부터는 XML 설정하지 않아도 됨
- Spring Security Architecture
구체적인 과정은 다음과 같다.
1. 사용자 로그인 정보가 담긴 HTTP 요청이 서버로 들어온다.
2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성
3. Filter를 통해 AuthenticationToken을 AuthenticationManager로 위임한다.
4. AuthenticationManager는 등록된 AuthenticationProvider들을 조회하며 인증을 요구한다.
5. AuthenticationProvider는 UserDetailsService를 통해 입력받은 사용자 정보를 DB에서 조회한다.
supports() 메서드를 통해 실행 가능한지 체크
authentication() 메서드를 통해 DB에 저장된 이용자 정보와 입력한 로그인 정보 비교
DB 이용자 정보 : UserDetailsService의 loadUserByUsername()을 통해 불러옴
입력 로그인 정보 : (4)애서 받았던 Authentication 객체(UsernameAuthentication Token)
http.httpBasic(HttpBasicConfigurer::disable) : JWT토큰을 사용하는 REST API 방식이기에 disable 처리
http.csrf(AbstractHttpConfigurer::disable) : REST API 형식으로 JSON으로만 데이터를 주고 받는 Stateless한 통신방식을 사용하기에 csrf 설정이 불필요해 disable 처리
http.sessionManagement() : 기존의 .sessionManagement().sessionCreationPolicy()가 deprecated된 후 개정된 코드이다.
.authorizeHttpRequests() : 토큰 없이도 접근이 허용되는 url을 설정할 수 있고, 허용 설정을 하지 않은 url은 모든 인증단계를 거쳐야 접근할 수 있다.
.addFilterBefore(filter A, filter B) : B 체인 전에 A 체인을 추가하는 역할의 메서드이다. 순서가 (JwtAuthenticationFilter(), UsernamePassword~.class) 이므로 UsernamePasswordAuthenticationFilter 전에 JwtTokenProvider를 실행한다. 즉, JwtFilter를 통과하면 UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것을 의미한다. 쉽게 말해 JWT를 통해 Username + Password 인증 수행을 의미한다.
secretKey는 JWt의 signature 부분을 암호화할 개인 시크릿키를 설정해주는 값이다. 값이 비밀로 유지되어야 하므로 yml파일에 jwt.secret이라는 이름으로 .gitignore 처리해서 저장해서 사용하면 된다. 그 후 @Value("${jwt.secret}")으로 런타임에 키를 가져와 사용할 수 있게 하면된다.
createToken()
JWT 토큰을 생성하기 위한 메서드로, AuthService에서 로그인하는 함수인 signIn()에서 사용된다.
// JWT 토큰 생성public TokenResponseDto createToken(String email, List<String> roles){
Claims claims = Jwts.claims().setSubject(email);
claims.put("roles", roles); // 정보 저장 (key-value)
Date now = new Date();
returnnew TokenResponseDto(Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature에 들어갈 secret 값 세팅
.compact());
}
setSubject() : JWT의 목적 같은 내용 추가
setClaim : JWT 토큰의 payload에 들어갈 내용으로, key-value 형태로 저장
signWith : JWT토큰의 signature를 의미하며, HS256으로 인코딩하고 secretkey를 이용한다는 것을 의미한다
getAuthentication()
// JWT 토큰에서 인증 정보 조회public Authentication getAuthentication(String token){
UserDetails userDetails =
userDetailsService.loadUserByUsername(this.getUserEmail(token));
returnnew UsernamePasswordAuthenticationToken(
userDetails,
"",
userDetails.getAuthorities()
);
}
UserDetailsService.loadUserbyUsername(this.getUserEmail(token)) : 토큰으로 사용자 이메일을 추출한 뒤 사용자 정보를 가져온다
인증용 객체인 UsernamePasswordAuthenticationToken을 반환한다(시큐리티 동작 과정중 (2)에 해당하는 내용)
getUserEmail()
매개변수로 입력된 token을 파싱해서 회원정보를 반환하는 함수이다.
// 토큰에서 회원 정보 추출public String getUserEmail(String token){
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
resolveToken()
HTTP request 헤더 Cookie에 있는 access token을 추출하는 메서드이다. 내가 구현한 애플리케이션에는 헤더 쿠키에 'access_token= ~'형태로 access token이 있기에 'access_token='으로 split한 문자열에서 쿠키를 가져온 뒤 bearerToken이 있다면 토큰을 반환하는 방식으로 구현했다.
public String resolveToken(HttpServletRequest request){
String cookie = request.getHeader("Cookie");
if (cookie != null) {
String bearerToken = cookie.split("access_token=")[1];
if (StringUtils.hasText(bearerToken)) {
return bearerToken;
}
}
returnnull;
}
isValidateToken()
유효한 토큰인지, 토큰이 만료되지 않았는지 확인하는 함수이다. boolean 값을 반환하므로 메서드명을 is~()형태로 명명했다.
UserDetailsService 인터페이스를 구현한 커스텀 서비스이고, JwtTokenProvider 클래스의 getAuthentication()메서드 내부에서 사용하는 클래스이다. 여기에서는 http 요청으로 넘어온 사용자 정보가 서버에 존재하는 사용자인지 확인하고 다음과 같은 결과를 반환한다.
성공 시 UserDetails 타입의 객체를 반환
실패 시 UsernameNotFoundException을 반환
@Service@RequiredArgsConstructorpublicclassCustomUserDetailsServiceimplementsUserDetailsService{
privatefinal MemberRepository memberRepository;
@Overridepublic UserDetails loadUserByUsername(String username)throws UsernameNotFoundException {
return memberRepository
.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
}
// 해당하는 Member 의 데이터가 존재한다면 User 객체로 만들어서 리턴private UserDetails createUserDetails(Member member){
returnnew SecurityUser(member);
}
}
loadUserByUsername 내부를 보면 memberRepository에서 이메일로 사용자를 찾고, 그 결과를 SecurityUser로 매핑해서 반환하는 것을 볼 수 있다(람다와 스트림을 사용했다). 만약 사용자가 존재하지 않는다면 예외를 throw 한다.
2-6. SecurityUser
위 커스텀 서비스의 createUserDetails() 메서드에서 반환하는 타입으로, 시큐리티의 User를 상속받고 있다. 매개변수로 넘어온 Member 객체의 정보로 User 생성자를 호출해 User 객체를 생성한다.
@Getter
public classSecurityUserextendsUser{
privateMember member;
public SecurityUser(Member member) {
super(member.getEmail(), member.getPassword(),
AuthorityUtils.createAuthorityList(member.getRole().toString()));
this.member = member;
}
}
서비스 계층은 가급적 특정 구현 기술에 의존하지 않고, 순수하게 유지하는 것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야한다.
그렇다면 서비스가 처리할 수 없는 SQLException에 대한 의존은 어떻게 제거할 수 있을까?
이는 리포지토리에서 SQLException 체크 예외를 런타임 예외로 전환해서 서비스 계층에 던지면 된다.
스프링 예외 추상화 이해
런타임 예외 변환으로 체크 예외에 의존하는 것은 해결했지만, 각 예외상황마다 오류 코드가 다른 경우가 있다. 예를 들어 키 중복 오류 코드는 H2의 경우 23505, MySQL의 경우 1062인 것 처럼 말이다. 문제는 이 오류코드가 하나의 데이터베이스에도 수백개인데 또 데이터베이스 별로 다 다르다는 것이다.
만약 각 오류 코드 별로 분기문을 만들어 진행한다면 코드 양이 방대해지고, DB를 바꾸게 되면 전체 코드를 바꿔야 할 수도 있다.
이 문제를 스프링은 데이터 접근과 관련된 예외를 추상화해서 해결해준다. 계층 구조는 다음과 같다.
스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다.
각 예외는 특정 기술에 종속적이지 않게 설계되어 있다. 따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
가장 상위에는 DataAccessException 예외가 있다. 이는 런타임 예외를 상속 받았기에 모두 런타임 예외이다.
DataAccessException
이는 NonTransient, Transient 예외로 구분된다.
Transient는 일시적이라는 뜻으로, Transient 하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다.
예를 들어 쿼리 타임아웃, 락과 관련된 오류들이 있다. 이런 오류들은 데이터베이스 상태가 좋아지거나, 락이 풀렸을 때 다시 시도하면 성공할 수도 있다.
NonTransient는 일시적이지 않다는 뜻이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다.
SQL 문법 오류, 데이터베이사 제약조건 위배 등이 있다.
스프링이 제공하는 예외 변환기
스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다. 구체적인 코드는 다음과 같다.