여기저기서 참고한 자료들을 가지고 혼자서 구현해 본 것이라 예외처리라던지 다소 불안정한 요소가 많지만 나중에 다듬을 때 참고하기 위해서 작성, 글 순서는 대략적인 구현 순서임, 일반 로그인은 구현 안함 오직 Oauth2로만 로그인 하도록 되어 있음
참고한 자료들: https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/bearer-tokens.html
OAuth 2.0 Bearer Tokens :: Spring Security
By default, Resource Server looks for a bearer token in the Authorization header. This, however, can be customized in a handful of ways. For example, you may have a need to read the bearer token from a custom header. To achieve this, you can expose a Defau
docs.spring.io
https://deeplify.dev/back-end/spring/oauth2-social-login#securityconfig
[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)
스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.
deeplify.dev
https://www.youtube.com/watch?v=GEv_hw0VOxE&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah
<Spring Boot>
<의존 설정>
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation group: 'com.auth0', name: 'java-jwt', version: '3.19.2'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
<Spring Security 설정>
먼저 Spring Security에서 모든 설정을 허용으로 하고 RestController에 @CrossOrigin을 제거함 그리고 나서 csrf, httpBasic,formlogin,session을 설정함 (jwt를 사용하기 위함) 그리고 oauth2는 loginPage정도만 설정해주고 점점 추가됨
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
private final UserRepository userRepository;
private final PrincipalOauth2UserService principalOauth2UserService;
private final CorsConfig corsConfig;
public SecurityConfig(PrincipalOauth2UserService principalOauth2UserService, CorsConfig corsConfig, UserRepository userRepository ) {
this.principalOauth2UserService = principalOauth2UserService;
this.corsConfig = corsConfig;
this.userRepository = userRepository;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests(authorize->authorize
.antMatchers("/api/board/**", "/api/largeCategory/**", "/api/subCategory/**")
.access("hasRole('ROLE_비밀☆')")
.antMatchers("/api/reply/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_비밀☆')")
.anyRequest().permitAll())
.logout(logout->logout
.logoutSuccessUrl("http://localhost:3000"))
.apply(new MyCustomDsl())
.and()
.oauth2Login(oauth2 -> oauth2
.loginPage("http://localhost:3000/login")
.userInfoEndpoint(userinfo -> userinfo
.userService(principalOauth2UserService))
.successHandler(oAuth2AuthenticationSuccessHandler())
.failureHandler(oAuth2AuthenticationFailHandler())
);
return httpSecurity.build();
}
public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
builder
.addFilter(corsConfig.corsFilter())
.addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
}
}
@Bean
public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
return new OAuth2AuthenticationSuccessHandler();
}
@Bean
public Oauth2AuthenticationFailHandler oAuth2AuthenticationFailHandler() {
return new Oauth2AuthenticationFailHandler();
}
}
<PrincipalDetails>
@Data
public class PrincipalDetails implements OAuth2User {
private User user;
private Map<String, Object> attributes;
public PrincipalDetails(User user) {
this.user=user;
}
//Oauth 로그인
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user=user;
this.attributes=attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add((GrantedAuthority) () -> user.getRoleType().toString());
return collection;
}
}
<PrincipalOauth2UserService>
securityConfig에 UserService를 받도록 설정해 주고, Oauth2로그인이 되면 해당 로그인 정보를 받아서 새로 user정보를 저장하고 저장되거나 이미 저정되어 있다면 authentication객체에 저장해줌 이때 authentication 객체에서 ROLE_USER와 같은 권한 설정을 받아옴(JWT만 사용하면 이러한설정이 어렵다.)
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public PrincipalOauth2UserService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {
this.userRepository = userRepository;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
//provider로 부터 받은 userRequest 후처리
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//System.out.println(userRequest.getAccessToken()); 접근 토큰
//System.out.println(userRequest.getClientRegistration()); goolge-api-console 에 대한 내용 client id, secret등등
//System.out.println(super.loadUser(userRequest).getAttributes());
//System.out.println(super.loadUser(userRequest).getAuthorities()); 권한 정보(ROLE)
//oauth2 로그인 완료 후 받은 회원 정보
OAuth2User oAuth2User = super.loadUser(userRequest);
//Oauth2UserInfo 초기화
OAuth2UserInfo oAuth2UserInfo = null;
//프로바이더에 맞춰서 Oauth2UserInfo 정보 입력
if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
}
// }else if(userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
// oAuth2UserInfo = new NaverUserInfo((Map) oAuth2User.getAttributes().get("response"));
// }else {
//
// }
//user에 넣을 정보
String provider = oAuth2UserInfo.getProvider();
String providerId = oAuth2UserInfo.getProviderID();
String username =provider+"_"+providerId;
String password = bCryptPasswordEncoder.encode(UUID.randomUUID().toString());
String email = oAuth2UserInfo.getEmail();
RoleType roleType = RoleType.ROLE_USER;
//해당 attribute로 가입된 유저 정보 찾기
User userEntity = userRepository.findByUsername(username);
//처음 로그인 시도라면 db에 유저 저장
if (userEntity == null) {
System.out.println("첫 oauth 로그인");
userEntity = User.builder()
.username(username)
.password(password)
.email(email)
.roleType(roleType)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(userEntity); //회원 정보로 자동 회원가입
} else {
System.out.println("이미 해당 oauth정보가 있음");
}
return new PrincipalDetails(userEntity, oAuth2User.getAttributes()); //=>authentication 객체
}
<CorsConfig>
react로 부터 응답을 받기위한 설정
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true); //내 서버가 응답을 할 때 json을 자바스크립트에서 처리할 수 있게 하는지 설정
configuration.addAllowedOriginPattern("*"); //모든 요청 응답 허용
configuration.addAllowedHeader("*"); //모든 header에 응답 허용
configuration.addAllowedMethod("*"); //모든 restful 요청 허용
source.registerCorsConfiguration("/**",configuration);
return new CorsFilter(source);
}
}
<BCryptPasswordEncoder>
처음 Oauth2로그인 시 유저 정보를 저장 할 때 비밀번호를 UUID에 BCrypt로 암호화 해줌 (근데 생각해 보니 일반 로그인은 안하니까 딱히 필요 없을지도...)
@Configuration
public class Bcrypt {
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
}
<Google User Info>
구글 Oauth2로그인을 하면 받는 정보들을 쓰기 편하게 풀어줌
package tnut.blogback.config.oauth2.provider;
import java.util.Map;
public class GoogleUserInfo implements OAuth2UserInfo{
private final Map<String, Object> attributes;
public GoogleUserInfo(Map<String, Object> attributes) {
this.attributes=attributes;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderID() {
return (String) attributes.get("sub");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
<OAuth2UserInfo>
각 UserInfo를 받을 것들을 체계화해 놓은 것
public interface OAuth2UserInfo {
String getProvider();
String getProviderID();
String getEmail();
String getName();
}
<JwtAuthorizationFilter>
springSecurity는 요청이 오면 항상 Filter를 타게 되어 있음 따라서 jwt를 발급해주고 필터에서 jwt를 확인하고 검증을 하고 jwt내부 정보로 권한을 주도록 하는 것임 아래는 springSecurity filter을 이용하여 jwt를 검증하도록 함
//BasicAuthenticationFilter 무조건 권한 인증 요구하는 페이지 요청시 거쳐가게 되어 있음
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("요청이 들어옴");
String accessToken = request.getHeader(HEADER_ACCESS);
//header에 jwt토큰이 제대로 들어가 있는지 확인
if (accessToken == null) {
System.out.println("jwt토큰이 없는 요청");
chain.doFilter(request, response); //jwt토큰이 없다면 그대로 filter 진행(authentication객체가 없으므로 후에 인가가 필요한 요청 x)
} else {
System.out.println("jwt토큰이 있는 요청");
//jwt토큰을 통해 비정상적인 사용자인지 확인 accessToken
String username = JWT.require(Algorithm.HMAC512(ACCESS_SECRET)).build()
.verify(accessToken)
.getClaim("username")
.asString();
if (username != null) { //유효한 jwt토큰이 들어왔다면
System.out.println("jwt토큰이 유효함");
User userEntity = userRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
//jwt 토큰 서명에 근거해서 만들어진 authentication 객체
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities()); //RoleType지정에 필요함
//강제로 시큐리티 세션에 접근하여 authentication객체를 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
}
<Oauth2AuthenticationFailHandler>
처음 Oauth2로그인이 실패할 경우 핸들러 그냥 홈페이지 화면으로 돌아가도록만 되어 있다.
@Component
@RequiredArgsConstructor
public class Oauth2AuthenticationFailHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("failHandler 작동함");
getRedirectStrategy().sendRedirect(request, response, "http://localhost:3000");
}
}
<OAuth2AuthenticationSuccessHandler>
여기서 좀 중요한데 Oauth2로그인이 성공하면 accessToken(요청에 대한 권한 정보를 가짐)과 refreshToken(보안상 만료기간이 짧은 accessToken을 재발급해주기 위한 토큰)을 jwt로 발급해주고 이를 프론트 단에 주소에 토큰들을 담아서 보내서 후에 react에서 localStorage에 저장되고 이게 나중에 요청시 header에 담겨서 보내지고 윗 필터를 거치게 됨
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private RefreshTokenService refreshTokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("success Handler 작동함");
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
//기존 username으로 있는 refresh토큰 삭제
if (refreshTokenService.refreshTokenFind(principalDetails.getUser().getUsername()) != null) {
refreshTokenService.refreshTokenDelete(principalDetails.getUser().getUsername());
}
String accessToken = JWT.create()
.withSubject(TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis() + ACCESS_EXPIRATION_TIME))
.withClaim("username", principalDetails.getUser().getUsername())
.sign(Algorithm.HMAC512(ACCESS_SECRET)); //내 서버만 아는 고유한 값
String refreshToken = JWT.create()
.withSubject(TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis() + REFRESH_EXPIRATION_TIME))
.withClaim("username", principalDetails.getUser().getUsername())
.sign(Algorithm.HMAC512(REFRESH_SECRET));
refreshTokenService.refreshTokenSave(refreshToken, principalDetails.getUser().getUsername()); //db에 refreshToken 저장
getRedirectStrategy().sendRedirect(request, response, REDIRECT_URL + "accessToken=" + accessToken + "&refreshToken=" + refreshToken); //front에 돌아갈 페이지를 말함
}
}
<RefreshController>
프론트에서 주기적으로 refreshToken을 통해서 accessToken을 재발급해줘야 로그인이 끊기지 않음-> refreshToken이 만료되면 그때 다시 로그인 해주면 됨 30일로 설정해 두었음
@RestController
public class RefreshController {
private final RefreshTokenService refreshTokenService;
public RefreshController(RefreshTokenService refreshTokenService) {
this.refreshTokenService = refreshTokenService;
}
@PostMapping("/refresh") //new access 토큰 발급
public ResponseDto<?> refresh ( HttpServletRequest request) { //refresh token을 받아서
//refresh Token 검증
String username = JWT.require(Algorithm.HMAC512(REFRESH_SECRET)).build()
.verify(request.getHeader("RefreshToken"))
.getClaim("username")
.asString();
if (username != null) {
String accessToken = JWT.create()
.withSubject(TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis() + ACCESS_EXPIRATION_TIME))
.withClaim("username", username)
.sign(Algorithm.HMAC512(ACCESS_SECRET));
System.out.println("accessToken 재발급 완료");
return new ResponseDto<>(HttpStatus.OK.value(), accessToken); //새로운 access토큰을 반환
} else {
//refresh token이 만료되었다면
return new ResponseDto<>(HttpStatus.UNAUTHORIZED.value(), "재 로그인이 필요합니다." );
}
}
}
<RefreshToken>
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class RefreshToken {
@JsonIgnore
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long refreshTokenId;
private String username; // refreshToken을 찾을 수단
private String refreshToken;
}
<RefreshTokenService>
@Service
public class RefreshTokenService {
public final RefreshTokenRepository refreshTokenRepository;
public RefreshTokenService(RefreshTokenRepository refreshTokenRepository) {
this.refreshTokenRepository = refreshTokenRepository;
}
@Transactional
public void refreshTokenSave (String refreshToken, String username) {
RefreshToken refreshTokenEntity = RefreshToken.builder()
.refreshToken(refreshToken)
.username(username)
.build();
refreshTokenRepository.save(refreshTokenEntity);
}
@Transactional
public RefreshToken refreshTokenFind (String username) {
return refreshTokenRepository.findByUsername(username);
}
@Transactional
public void refreshTokenDelete (String username) {
refreshTokenRepository.deleteByUsername(username);
}
<RefreshTokenRepository>
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
RefreshToken findByUsername(String username);
void deleteByUsername(String username);
}
'개인 프로젝트 > 블로그' 카테고리의 다른 글
springSecruty+JWT+react+Oauth2 (react) (0) | 2022.07.18 |
---|---|
springSecurity+react+Oauth2 전체 흐름 (0) | 2022.07.18 |
로그 아웃 고민 (0) | 2022.07.18 |
refresh 토큰 후처리 고민 (0) | 2022.07.17 |
로그인 구현 생각 (0) | 2022.07.10 |