이전 포스팅
이전 포스팅에서, 어떻게 해야 보다 더 안전하고 좋은 로그인 로직을 구현해내는지에 대해 알아보고 고민해보았습니다. 이번 포스팅에서는 이론을 바탕으로 실제 구현을 해보려고 합니다.
로그인 진행 방식
이전 포스팅의 내용을 요약하면 다음과 같습니다.
- 클라이언트는 ID + PW의 조합으로 서버에 로그인 요청 -> /login
- 서버는 ID와 PW가 일치하면 accessToken, 그리고 RefreshToken이 담긴 쿠키를 담아 응답
- 클라이언트는 accessToken으로 서버에 접근.
- 클라이언트는 페이지 리로딩이나 accessToken 만료 1분 전에 로그인 연장 요청 -> /login-extension
- 쿠키에 담긴 refreshToken 정보가 DB에 있는 정보와 일치할 시 accessToken, refreshToken 재발급 하여 전송
- 만약 RefreshToken간 매칭이 되지 않을 경우, 서버는 이를 악의적인 침투가 있는것으로 간주,
- DB에 저장된 리프레쉬 토큰을 날리고 사용자를 로그아웃 처리
- 이후 클라이언트는 사용자에게 다시 로그인하도록 유도
이전 포스팅 마지막 부분에, Redis를 이용한 블랙리스트 관리를 해보겠다고 하였다고 언급하였지만, 이 부분은 구현하지 않으려고 합니다. 그 이유는, 아직 규모가 크지 않은 프로젝트에서 Redis까지 추가로 운용하여 인증을 관리하는것은 리소스 비용이 추가로 들어서 비효율적이라고 생각이 들었기 때문입니다.
물론, 실제 실무나 중요한 프로젝트에서는 적용하여 보안수준을 더 증가시키면 좋겠지만 저희 프로젝트는 치명적일 수 있는 결제정보등의 중요한 개인정보가 없다는 점, 실제 트래픽이 높지 않다는 점을 감안하여 내린 결론입니다.
이제 구현을 시작해보겠습니다.
기본 준비사항
제가 미리 작성해둔 Security 인증 로직입니다. 위 시리즈에서는 처음 설정부터 JWT기반 인증 로직까지 적용할 수 있는 방법을 설명하고 있습니다. 위 시리즈대로 모든 설정을 끝마치고 오신 다음 현재 포스팅을 따라오면 됩니다.
위 시리즈에서는 Security + JWT 인증 로직이 적용되어 있습니다. 현재 포스팅에서는 무엇을 할 것이냐면, 이전 포스팅에서 작성한 대로 쿠키와 토큰을 이용한 로그인 로직 커스터마이징 + 시큐리티 로그인 응답 형식 커스터마이징 + 프론트(JS + AOS)와 함께하는 소셜 로그인 적용 을 해보려고 합니다.
갑자기 소셜 로그인?
갑자기 내용이 많아졌네요.. 이전에 사실 소셜 로그인을 구현하려고 하였는데, 따로따로 작성하기 좀 힘들 것 같아 이번 글에서 통합하여 작성하려고 합니다. 이 글을 읽는 분들이, 제가 여러가지 작성한 글들을 통해 시큐리티를 자유자재로 사용할 수 있다면 좋을 것 같습니다.
프론트와 소셜 로그인 하는 방식에 대해 제가 따로 정리한 글이 있습니다.
https://100cblog.tistory.com/29
이 글에서 언급한 대로, 이번 글에서 프론트엔드 부분과 협업하여 소셜 로그인도 함께 구현할 수 있도록 하겠습니다.
Security 준비
이 포스팅에서는 스프링 부트 3.2.4 버전을 사용하고, 스프링 시큐리티 6.2.3 버전을 사용합니다.
https://100cblog.tistory.com/38
먼저, 기본 개념에 대해 정리해둔 글이 있습니다.위 포스팅에 제가 간단하게 개념을 정리해두었습니다. 먼저 보고 오면 이해가 더 잘될 것입니다.
구현 시작
위에 언급하였듯이 스프링 부트 3.2.4 버전을 사용하고, 스프링 시큐리티 6.2.3 버전을 사용합니다. 그래서 다른 분들이 해놓은 글이 많이 없어 공식 문서를 참고하여 해보았습니다. 그러다 보니 틀린 부분이 있을 수 있는데, 있다면 적극적인 피드백 부탁드립니다!!
의존성 추가
스프링 시큐리티를 사용하기 위해 필요한 의존성입니다. build.gradle에 추가하시면 됩니다.
//== 스프링 시큐리티 ==//
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'
//== OAuth2 ==//
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
//json 웹토큰
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
- 스프링 시큐리티를 사용하기 위한 스타터
- 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성
- 스프링 시큐리티를 테스트 하기 위한 의존성
- OAuth2 를 이용하기 위한 의존성
- JWT 라이브러리 의존성
멤버 엔티티
멤버 엔티티를 먼저 구현하였습니다.
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_idx")
private Long idx;
private String memberId;
private String password;
private String nickName;
private String email;
@Column(length = 1000)
private String refreshToken;
@Enumerated(EnumType.STRING)
private Role role;
@Enumerated(EnumType.STRING)
private ActiveState activeState;
@Enumerated(EnumType.STRING)
private SocialType socialType;
}
위와 같이 기본적인 필드로 구성을 하였고, 추가로 필요한 부분이 있다면 따로 구현하시면 됩니다.
소셜 로그인과 일반 로그인이 함께 사용되는 로그인 입니다. memberID는 소셜 로그인의 경우 소셜 플랫폼에서 제공하는 고유 ID, 일반 로그인의 경우 email로 설정하여 앞으로 유저 PK와 같은 역할을 수행하도록 하겠습니다.
스프링 부트 3.0 이상 - 시큐리티 설정
먼저 config 패키지에 SecurityConfig라는 시큐리티 설정 파일을 만들어 줍니다. 필요한 @bean들을 추가해 사용할 수 있습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Lazy
public class SecurityConfig {
private final ObjectMapper objectMapper;
private final CustomUserDetailsService userDetailsService;
private final JwtService jwtService;
private final MemberRepository memberRepository;
private final CorsProperties corsProperties;
private final AccessTokenAuthenticationProvider provider;
// 스프링 시큐리티 기능 비활성화
@Bean
public WebSecurityCustomizer configure() {
return (web -> web.ignoring()
// .requestMatchers(toH2Console())
.requestMatchers("/fcm", "/static/**", "/h2-console/**",
"/favicon.ico", "/error", "/swagger-ui/**",
"/swagger-resources/**", "/v3/api-docs/**")
);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http .csrf(AbstractHttpConfigurer::disable)
.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer
.configurationSource(corsConfigurationSource()))
.headers(headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // For H2 DB
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers( "/api/member/signup", "/", "/api/member/isDuplicated", "/api/member/login/oauth").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
;
http
.exceptionHandling(ex -> ex
.accessDeniedHandler(new CustomAccessDeniedHandler(jwtService))
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()))
.addFilterAfter(silentReAuthenticationFilter(), LogoutFilter.class)
.addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class)
.addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(auth2AccessTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Bean
public static PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {//2 - AuthenticationManager 등록
DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
return new ProviderManager(provider);
}
@Bean
public LoginSuccessJWTProvideHandler loginSuccessJWTProvideHandler(){
return new LoginSuccessJWTProvideHandler(jwtService, memberRepository);
}
@Bean
public LoginFailureHandler loginFailureHandler(){
return new LoginFailureHandler();
}
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return jsonUsernamePasswordLoginFilter;
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationProcessingFilter(){
return new JwtAuthenticationFilter(jwtService, memberRepository);
}
@Bean
OAuth2AccessTokenAuthenticationFilter auth2AccessTokenAuthenticationFilter() {
return new OAuth2AccessTokenAuthenticationFilter(provider, loginSuccessJWTProvideHandler(), loginFailureHandler());
}
@Bean
public SilentReAuthenticationFilter silentReAuthenticationFilter() {
return new SilentReAuthenticationFilter(jwtService, memberRepository);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(",")));
corsConfiguration.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(",")));
corsConfiguration.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(",")));
corsConfiguration.setExposedHeaders(Arrays.asList("Authorization", "Authorization-refresh"));
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setMaxAge(corsConfiguration.getMaxAge());
UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();
corsConfigSource.registerCorsConfiguration("/**", corsConfiguration);
return corsConfigSource;
}
}
모든 구현을 완료해놓고 작성하는 글이라, 아직 설명 안된 코드가 많습니다. 그런 부분은 추후 설명하도록 하겠습니다.
코드 설명
- Configure() : 스프링 시큐리티의 모든 기능(인증, 인가 등)을 사용하지 않게 설정
- requestMatchers() : 특정 요청과 일치하는 url에 대한 액세스 설정
- ignoring() : requestMatchers에 적힌 url에 대해 인증, 인가를 하지 않음.
- filterChain() : 특정 Http 요청에 대해 웹 기반 보안 구성. 인증/인가 및 로그아웃을 설정한다.
- csrf(Cross site Request forgery) : 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것. 즉, 정상적인 사용자가 의도치 않은 위조요청을 보내는 것을 의미한다.
- 저는 REST API를 이용한 개발을 진행 할 예정입니다.Rest Api 환경에서는 Session 기반 인증과 다르기 때문에 서버에 인증정보를 보관하지 않고, 권한 요청시 필요한 인증정보(OAuth2, Jwt토큰 등)요청을 포함하기 때문에 굳이 불필요한 csrf 보안을 활성화할 필요가 없습니다.
- 따라서 csrf는 disable 처리 하였습니다.
- HttpBasic(), FormLogin() : Json을 통해 로그인을 진행하는데, 로그인 이후 refresh 토큰이 만료되기 전까지 토큰을 통한 인증을 진행할것 이기 때문에 비활성화 하였습니다.
- HttpBasic() : Http basic Auth 기반으로 로그인 인증창이 뜬다.
- authorizeHttpRequests() : 인증, 인가가 필요한 URL 지정
- anyRequest() : requestMatchers에서 지정된 URL외의 요청에 대한 설정
- authenticated() : 해당 URL에 진입하기 위해서는 인증이 필요함
- requestMatchers("Url").permitAll() : requestMatchers에서 지정된 url은 인증, 인가 없이도 접근 허용
- Url에 /**/ 와 같이 ** 사용 : ** 위치에 어떤 값이 들어와도 적용 (와일드 카드)
- hasAuthority() : 해당 URL에 진입하기 위해서 Authorization(인가, 예를 들면 ADMIN만 진입 가능)이 필요함
- .hasAuthority(UserRole.ADMIN.name()) 와 같이 사용 가능
- formLogin() : Form Login 방식 적용
- loginPage() : 로그인 페이지 URL
- defaultSuccessURL() : 로그인 성공시 이동할 URL
- failureURL() : 로그인 실패시 이동할 URL
- logout() : 로그아웃에 대한 정보
- invalidateHttpSession() : 로그아웃 이후 전체 세션 삭제 여부
- sessionManagement() : 세션 생성 및 사용여부에 대한 정책 설정
- SessionCreationPolicy() : 정책을 설정합니다.
- SessionCreationPolicy.Stateless : 4가지 정책 중 하나로, 스프링 시큐리티가 생성하지 않고 존재해도 사용하지 않습니다. (JWT와 같이 세션을 사용하지 않는 경우에 사용합니다)
- csrf(Cross site Request forgery) : 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것. 즉, 정상적인 사용자가 의도치 않은 위조요청을 보내는 것을 의미한다.
로그인(/login), 회원가입 (/signup), 메인 페이지(/)에 대해서는 인증 없이도 접근할 수 있게 만들었습니다.
Configure 작성 문법 바뀐 부분
기존의 많은 분들은 스프링 2.X 버전을 사용중이실텐데요, 스프링 3.0 이상의 버전부터는 스프링 시큐리티 버전도 바뀌어서 기존의 Configuration과는 다르게 작성해야합니다.
WebSecurity, HttpSecurity 모두 큰 변화를 맞이 했는데, 그중 하나가 lambdas 형식의 작성법인거 같습니다.
위에 Http 설정의 구성을 보면 알 수 있듯이, 비활성화를 위해 csrf().disable()같은 형식으로 사용 했었던 과거와 비교하여, 파라미터로 함수를 전달(.csrf(AbstractHttpConfigurer::disable))하는 것을 볼 수있습니다.
formLogin()와 같은 설정의 경우도 마찬가지로 기존에 formLogin().loginPage() 형식으로 사용했다면 이제는 람다식을 파라미터로 전달하여 아래와 같이 사용합니다.
.formLogin(formLogin -> formLogin
.loginPage("/login")
.defaultSuccessUrl("/home"))
PasswordEncoder
Spring Security에서 비밀번호를 안전하게 저장할 수 있도록 비밀번호의 단방향 암호화를 지원하는데, 그것이 바로 PasswordEncoder 인터페이스와 구현체들입니다.
- encode() : 비밀번호를 암호화(단방향)
- matches() : 암호화된 비밀번호와 암호화되지 않은 비밀번호가 일치하는지 비교
- upgradeEncoding() : 인코딩된 암호화를 다시 한번 인코딩 할 때 사용 (true일 경우 다시 인코딩, default=false)
SecurityConfig 파일을 보면, 비밀번호 암호화 방식을 사용하기 위해 아래와 같은 암호화 방식을 사용하였습니다.
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
스프링 시큐리티에서 제공하며 bcrypt 해싱 함수로 암호를 인코딩하는 BCryptPasswordEncoder를 직접 불러서 사용하였습니다.
참고 : bcrypt?
bcrypt는 암호화 할 때 해시 알고리즘인 SHA-256을 사용합니다.해시 알고리즘의 대표적인 특성은, '암호화는 가능하나 복호화는 불가능하다'입니다. 즉, DB에 암호화되어 있는것을 해독할 수는 없다는 이야기입니다.
가끔 어느 사이트에서 비밀번호를 잊어버린 상황에서, '비밀번호를 찾고싶은데 왜 재설정하라고 하지?'라는 의문이 든적이 있을 겁니다.
그 이유가 바로 복호화가 불가능 하기 때문에 기존 값을 대체할 새로운 값을 넣어야하기 때문입니다.
- 그렇다면 비교는 어떻게 할까요?
-> 이 부분은 정확히 알기는 어렵지만, DB 저장 시 솔트 로직이라는 것을 같이 넣어서 비교하는것으로 알고있습니다.(정확한 방식은 찾아보시길 바랍니다..) 저희는 이것을 스프링에서 제공하는 matches() 함수를 통해 진행합니다.
그러다 문득 이런생각이 들었습니다. 만약, 제가 사용하고 있는 이 인코딩 방식에 문제점이 생긴다면 어떻게 유연하게 대응해야 할까요?
스프링 시큐리티에는 여러 인코딩 알고리즘을 지원하고 있고, 그중 특정 애플리케이션 버전부터 인코딩 알고리즘이 변경된 경우가 있습니다.
참고 : PasswordEncoder가 제공하는 구현 클래스
- StandardPasswordEncoder : SHA-256을 이용해 암호를 해시한다. (강도가 약한 해싱 알고리즘이기 때문에 지금은 많이 사용되지 않는다.)
- Pbkdf2PasswordEncoder : PBKDF2를 이용한다.
- BCryptPasswordEncoder : bcrypt 강력 해싱 함수로 암호를 인코딩한다
- NoOpPasswordEncoder : 암호를 인코딩하지 않고 일반 텍스트로 유지(테스트 용도로만 사용한다.)
- SCryptPasswordEncoder : scrypt 해싱 함수로 암호를 인코딩한다.
현재 사용되는 알고리즘에서 취약성이 발견되어 다른 인코딩 알고리즘으로 변경하고자 할 때 대응하기 좋은 방법은 DelegatingPasswordEncoder을 사용하는 것입니다.
DelegatingPasswordEncoder는 여러 인코딩 알고리즘을 사용할 수 있게 해주는 기능입니다. 저는 이제 이 방식을 사용하여 스프링 시큐리티를 구성해보겠습니다.
PasswordEncoderFactories.createDelegatingPasswordEncoder(); 메서드를 통해 PasswordEncoder를 반환하도록 하였습니다.
PasswordEncoder의 내부를 살펴보겠습니다.
encodingId는 "bcrypt"입니다. 이는 BCryptPasswordEncoder와 매핑됩니다.
DelegatingPasswordEncoder을 살펴보면, 먼저 Prefix인 "{"와 Suffix인 "}"의 유무를 판단해줍니다.
그 후, passwordEncoderForEncode는 "idForEncode"에 담긴 bcrypt를 통해 받아온 BCryptPasswordEncoder를 사용하는 DelegatingPasswordEncoder를 사용한다는 것을 알 수 있습니다.
DelegatingPasswordEncoder
encode 메서드는 아래와 같습니다.
return 값을 보면, {bcrypt}BCryptPasswordEncoder로 암호화된 문자열인 것을 알 수 있습니다.
참고로, 스프링 시큐리티 5.0이상부터는 암호화된 password에 {bcrypt}와 같이 암호화 방식을 명시해주지 않으면 에러가 발생한다고 합니다.
BCryptPasswordEncoder
BCryptPasswordEncoder는 어떻게 암호화하는지도 코드를 통해 한번 살펴보겠습니다.
getSalt()를 통해 임의의 문자열을 받아온 후, 아래 메서드를 호출합니다. (설명은 주석을 참고)
public static String hashpw(byte passwordb[], String salt) {
.......
if (salt == null) { //임의의 문자열인 salt가 null이라면 예외를 발생시킵니다.
throw new IllegalArgumentException("salt cannot be null");
}
int saltLength = salt.length();//랜덤 문자열의 길이를 구합니다.
if (saltLength < 28) {//임의의 문자열의 길이가 28글자보다 작을 경우 예외를 발생시킵니다.
//즉 BCryptPasswordEncoder에서 사용하는 salt는 28글자 이상의 임의의 문자열을 필요로 한다는것을 알 수 있습니다.
throw new IllegalArgumentException("Invalid salt");
}
......
}
출처: https://ttl-blog.tistory.com/268 [Shin._.Mallang:티스토리]
다음 포스팅에서는 필터를 커스텀해서 사용해보겠습니다.
참고 사이트
누락되거나 없는 내용
이번 포스팅은 제가 예전 벨로그에서 작성한 글을 기반으로 재구성하여 재작성 하는 글입니다. 혹시 빠진 부분이나 지금 바로 알고싶은 내용이 있다면 블로그 글 또는 깃허브 레포를 참고하면 될 것 같습니다.
https://github.com/official-Trippy/server