드디어 로그인 유지 기능 구현 !
이 부분에서 정말 많은 시간을 보냈다. 헤매기도 많이 헤매고 조금 힘들었지만 그만큼 다 해냈을 때 성취감은 말로 이룰 수 없었다.
일단 이전 로그인 구현은 react-google-login이라는 라이브러리를 사용해서 프론트단에서 access-token을 바로 얻게되었다.
하지만 이전 포스팅에도 적어놨듯이 이게 여간 찝찝한게 아니었다 .. 구현은 되긴 하는데 (비록 이메일과 이름만 받아오는거긴 하지만) 보안상으로도 엄청난 실수를 하는 느낌. (그치만 이 라이브러리가 존재하는 이상 어떻게 어떻게 안전하게 구현할 수 있는 방법이 있을 것 같기는 하다.)
그래서 google server에서 authorization code를 받은 뒤 백엔드 서버에 보내고, 백엔드단에서 google에서 access token을 받아오는 식으로 다시 구현했다. 완전 대공사가 이루어졌다. 혼자서는 절대 못 했겠지만 역시 구글링의 힘.. 아래 블로그의 도움을 많이 받았다.
https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/
감사합니다 ..
구현한 전체적인 프로세스는 다음과 같다.
1. 프론트단에서 사용자가 로그인을 원할 경우, google에서 제공하는 url로 이동시킨다 (접근 권한을 허용하는 페이지).
2. 사용자가 성공적으로 권한을 허용하면 google server는 정해진 url에 authorization code를 query string으로 추가하여 백엔드단에 리다이렉션 시킨다 (http://localhost:8080/oauth2/callback/google).
3. code를 받아온 백엔드는 이 code를 가지고 google에 요청하여 access token으로 바꾼다.
4. access token으로 성공적으로 교환했을 시, 서버 자체적으로 jwt token을 만들고 이 token을 header에 넣어 프론트단의 정해진 redirect url (http://localhost:3000/oauth2/redirect)으로 응답을 보내준다.
5. token을 받은 프론트단은 이 토큰을 local storage에 저장하고, http request를 보낼 때 사용한다.
그럼 더 세세히 들어가보자 . . .
Configuration
- SecurityConfig
package com.frombooktobook.frombooktobookbackend.config;
import com.frombooktobook.frombooktobookbackend.security.jwt.JwtAccessDeniedHandler;
import com.frombooktobook.frombooktobookbackend.security.jwt.JwtAuthenticationEntryPoint;
import com.frombooktobook.frombooktobookbackend.security.jwt.TokenAuthenticationFilter;
import com.frombooktobook.frombooktobookbackend.security.*;
import com.frombooktobook.frombooktobookbackend.security.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private CustomOauth2UserService customOauth2UserService;
@Autowired
private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
@Autowired
private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailurehandler;
@Autowired
private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter();
}
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf()
.disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.formLogin()
.disable()
.httpBasic()
.disable()
.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.and()
.authorizeRequests()
.antMatchers("/",
"/h2-console/**",
"/error",
"/favicon.ico",
"/**/*.png",
"/**/*.gif",
"/**/*.svg",
"/**/*.jpg",
"/**/*.html",
"/**/*.css",
"/**/*.js")
.permitAll()
.antMatchers("/auth/**","/oauth2/**")
.permitAll()
// 기본 게시물 둘러보기는 누구나 가능하도록
.antMatchers("/post")
.permitAll()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository())
.and()
.redirectionEndpoint()
.baseUri("/oauth2/callback/*")
.and()
.userInfoEndpoint()
.userService(customOauth2UserService)
.and()
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailurehandler);
// custom Token based authentication filter 추가
http.addFilterBefore(tokenAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
}
}
@EnableWebSecurity
Spring Security를 활성화 시킨다.
@EnableGlobalMethodSecurity
- prePostEnabled = true : @PreAuthorize, @PostAuthorize 어노테이션 사용을 허용
controller에서 특정 권한이 있는 유저만 접근을 허용하고 싶은 경우 @PreAuthorize와 @PostAuthorize 어노테이션을 사용할 수 있다. 각각 특정 메서드 호출의 전과 후에 권한을 검사한다.
@PreAuthorize 어노테이션은 함수를 실행하기 전, 권한을 검사한다.
'#파라미터명' 을 사용하여 파라미터 객체에 접근할 수 있다.
@PostAuthorize 어노테이션은 함수를 실행한 뒤, 클라이언트에 응답을 하기 전에 권한을 검사한다.
'returnObject' 예약어를 사용하여 메서트의 리턴 객체에 접근할 수 있다.
ex) 현재 로그인 중이며, 파라미터의 user.name과 현재 로그인 중인 사용자의 이름 (authentication.principal.name)이 일치하는지 확인, 일치할 경우에만 메소드 실행
@PreAuthorize("isAuthenticated() and ((#user.name == authentication.principal.name) or hasRole('ROLE_ADMIN'))")
@RequestMapping(value="",method=RequestMethod.PUT)
public ResponseEntity<message> updateUser(User user) {
userService.updateUser(user);
return new ResponeEntity<message>( new message(), HttpStatus.OK );
}
ex) 현재 로그인 중이며, return 되는 객체의 name과 (returnObject.name) 현재 로그인 중인 사용자의 이름 (authentication.principal.name)이 일치하는지 확인, 일치할 경우에만 클라이언트에 응답을 보냄.
@PostAuthorize("isAuthenticated() and (( returnObject.name == authentication.principal.name ) or hasRole('ROLE_ADMIN'))")
@RequestMapping( value = "/{userId}", method = RequestMethod.GET )
public User getuser( @PathVariable("userId") long userId ){
return userService.findbyId(userId);
}
- securedEnabled = true : @Secured 어노테이션 사용을 허용
- jsr250Enabled = true : @RolesAllowed 애노테이션 사용을 허용
@Secured , @RollesAllowed 어노테이션을 통해 특정 메서드 호출 이전에 권한을 확인할 수 있다.
전자는 스프링에서 지원하는 어노테이션이며 후자는 자바 표준 어노테이션이다.
그럼 @PreAuthorize와 다른 점은 뭐지?
@PreAuthorize 와 차이점이 있다면, 표현식을 사용할 수 없다는 점이다.
ex) hasRole, principal, authentication, isAuthenticated() 등등을 사용할 수 없음
ex)
@Secured("ROLE_USER")
@RolesAllowed("ROLE_USER")
public void showProfile() {
}
CustomUserDetailService
form 로그인으로 요청이 들어올 경우 인증을 위한 userDetail를 만들어내는 userDetailService이다.
CustomOauth2UserService
Oauth2 로그인으로 요청이 들어올 경우 인증을 위한 userDetail를 만들어내는 userDetailService이다.
OAuth2AuthenticationSuccessHandler
OAuth2 권한 인증을 성공적으로 마친 뒤 수행되는 SuccessHandler이다.
토큰을 만들고 (by tokenProvider) redirect_uri에 토큰을 함께 보내는 작업을 수행한다.
OAuth2AuthenticationFailureHandler
OAuth2 권한 인증에 실패했을 경우 수행되는 FailureHandler이다.
redirect_uri에 에러를 보낸다.
HttpCookieOAuth2AuthorizationRequestRepository
OAuth2 protocol은 CSRF 공격을 예방하기위해 state 파라미터를 사용할 것을 권장하고 있다.
인증 동안에 어플리케이션은 이 파라미터를 OAuth2AuthorizationRequest에 담아서 보내고, OAuth2 provider는 이 파라미터를 callback에서 그대로 다시 보내준다. application은 callback으로 받은 state와 처음에 보냈던 state값을 비교하고, 만약 이 둘이 같지 않다면 authentication request를 거부시킨다. 이를 수행하기 위해서 application은 OAuth2AuthorizationRequest에 담긴 state를 어디엔가 저장해야 한다. (향후에 비교하기 위해서)
AuthorizationRequestRepository가 인가 요청을 시작한 시점부터 인가 요청을 받는 시점까지 (콜백) OAuth2AuthorizaionRequest를 유지해준다. 디폴트 구현체가 HttpSession에 OAuth2AuthorizaionRequest를 저장하는 HttpSessionOAuth2AuthorizationRequestRepository이다.
쉽게 얘기해서 디폴트로는 세션에 요청 정보를 저장해둔다는 것인데, 이 부분에 대해서는 스프링에서 커스터마이징이 가능한 부분을 제공하고 있다. REST 서비스를 하는 경우에는 세션을 생성하지 않기 때문에 다른 방식으로 레포지토리를 구현해야 한다.
여기서는 이 state를 redirect_uri 와 함께 short-lived cookie에 저장하는 식으로 구현했다.
JwtAuthenticationEntryPoint
인증에 실패할 경우에 대한 처리 클래스이다. (ex: id와 password가 일치하지 않음)
위 그림을 보면 이해가 확 간다.
인증 과정에서 인증에 실패 (401, UnAuthorized)한다면 이 AuthenticationEntryPoint의 commence 메소드가 실행된다.
여기서 로그인 페이지로 리다이렉트 해주거나 response의 HttpStatus로 unauthorized를 넣어 보낸다.
나는 REST API 구현중이므로 response에 에러를 넣는 것으로 구현했다.
JwtAccessDeniedHandler
권한 인증에 실패 (403, Forbidden) 에 대한 처리 클래스이다.
인증은 되었지만 권한이 없는 사항에 대해 요청이 들어온 경우, AccessDeniedHandler의 handle 메소드가 실행된다.
TokenAuthenticationFilter
코드를 보면 다음과 같이 작성되어 있다.
http.addFilterBefore(tokenAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
이는 UsernamePasswordAuthenticationFilter가 실행되기 전에, 커스텀 필터인 tokenAuthenticationFilter를 실행하겠다는 뜻이다.
즉 usernamePassword authentication 인증이 실행되기 전에 실행되며, request에 jwt token이 존재하는 경우 이를 검증하고 SpringContext로 등록한다. (즉 jwt access token이 존재하는 경우 SprinContext에 바로 등록)
- WebMvcConfig
package com.frombooktobook.frombooktobookbackend.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(MAX_AGE_SECS);
}
}
CORS 에러가 뜨지 않도록 처리해주는 클래스이다.
- addMapping("/**") : CORS를 적용할 URL 패턴을 와일드 카드를 사용하여 정의
- allowedOrigins("http://localhost:3000") : 프론트단과의 리소스 공유 허용
- allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS") : 모든 method 허용
- allowedHeaders("*") : 어떤 헤더든 허용
- allowCredentials(true) : 쿠키 요청을 허용한다.
- maxAge(MAX_AGE_SECS) : preflight 요청에 대한 응답을 브라우저에 캐싱하는 시간
CORS 요청을 보낼 때, 브라우저는 해당 요청을 두 가지 방식으로 처리한다.
Preflight request와 Simple request이다.
1. Simple request
이 방식은 일반 요청과 동일하게 동작하며, 응답의 Access-Control-Allowed-Origin 유무에 따라 브라우저가 응답을 정상적으로 처리할 지를 결정한다는 점만이 다르다.
예를 들어, localhost:8080이 localhost:9000/api에 요청을 보내고 Access-Control-Allow-Origin 헤더 없는 응답을 받았다면 브라우저는 응답을 자바스크립트 코드에 노출시키지 않고 오류를 내보낸다.
2. Preflight request
이 방식에서 브라우저는 실제 요청을 보내기 전에 preflight request를 실제 요청 메세지보다 먼저 서버에 보낸다. 서버는 이에 대한 응답을 주고, 브라우저는 이를 통해 실제 요청을 보낼지 말지를 결정한다.
preflight request는 OPTIONS 메서드를 사용하고, 요청 헤더에 '실제 요청에서 사용할 메서드(Access-Control-Request-Method)', '실제 요청에 포함될 헤더(Access-Control-Request-Headers)', '요청을 보내는 출처(Orign)' 에 관한 정보가 담겨있다.
같은 origin에서 http 통신을 하는 경우에는 cookie가 알아서 request header에 들어가게 된다.
하지만 CORS는 기본적으로 쿠키를 요청으로 보낼 수 없도록 막고 있다. origin이 다른 http 통신을 할 때에는 request header에 cookie가 자동으로 들어가지 않으며, 직접 설정을 해주어야 한다.
쿠키를 요청에 포함하고 싶다면 다음 2가지 작업을 해주어야 한다.
요청 request에서 (프론트) : withCredentials: true 설정
+ 현재 프로젝트에 사용된 ajax를 위해 도입된 fetch api를 사용할 경우 request header에 "Authorization" , "Bearer <token>" 을 넣어 보내주면 됨.
요청을 받은 서버에서 : Access-Control-Allow-Credentials : true 설정
- AppProperties
package com.frombooktobook.frombooktobookbackend.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private final Auth auth = new Auth();
private final OAuth2 oauth2 = new OAuth2();
public static class Auth {
private String tokenSecret;
private long tokenExpirationMsec;
public String getTokenSecret() {
return tokenSecret;
}
public void setTokenSecret(String tokenSceret) {
this.tokenSecret = tokenSceret;
}
public long getTokenExpirationMsec() {
return tokenExpirationMsec;
}
public void setTokenExpirationMsec(long tokenExpirationMsec) {
this.tokenExpirationMsec = tokenExpirationMsec;
}
}
public static final class OAuth2 {
private List<String> authorizedRedirectUris = new ArrayList<>();
public List<String> getAuthorizedRedirectUris() {
return authorizedRedirectUris;
}
public OAuth2 authorizedRedirectUris(List<String> authorizedRedirectUris) {
this.authorizedRedirectUris = authorizedRedirectUris;
return this;
}
}
public Auth getAuth() {
return auth;
}
public OAuth2 getOauth2() {
return oauth2;
}
}
다음은 본격 API에 대해서 작성해보겠다.
'Project > FromBookToBook' 카테고리의 다른 글
[FBTB] 4. 로그인 유지 기능 구현 (UI) (0) | 2022.04.22 |
---|---|
[FBTB] 4. 로그인 유지 기능 구현 (OAuth2/JWT 관련 API) (0) | 2022.04.22 |
(임시) user/password springSecurity 클래스들 (0) | 2022.04.12 |
[FBTB] 3. 로그인 기능 구현 (with Oauth2) (0) | 2022.04.07 |
[FBTB] 2. 독후감 목록 기능 구현 (0) | 2022.04.01 |