관련 도메인
- OAuth2UserInfo
package com.frombooktobook.frombooktobookbackend.security.user;
import java.util.Map;
public abstract class OAuth2UserInfo {
// 키-값 쌍의 일반 Map에서 사용자의 필수 세부 사항을 가져오는데 사용
protected Map<String,Object> attributes;
public OAuth2UserInfo(Map<String,Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
public abstract String getEmail();
public abstract String getImageUrl();
public abstract String getName();
}
userInfo의 틀 역할.
google, naver, facebook 등등 각 provider마다 서로 다른 json response를 보내온다.
Oauth2UserInfo로 Spring Security가 사용하는 form을 잡아놓고, 이를 상속하여 각기 다른 provider로부터 온 reponse들을
form에 맞게 통일시킨다.
- GoogleOAuth2UserInfo
package com.frombooktobook.frombooktobookbackend.security.user;
import java.util.Map;
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String,Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
@Override
public String getName() { return (String) attributes.get("name"); }
}
아직 구글 로그인밖에 구현 안 했다 ㅎ
- OAuth2UserInfoFactory
package com.frombooktobook.frombooktobookbackend.security.user;
import com.frombooktobook.frombooktobookbackend.domain.user.ProviderType;
import java.util.Map;
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map<String,Object> attributes) {
switch(providerType) {
case GOOGLE: return new GoogleOAuth2UserInfo(attributes);
default: throw new IllegalArgumentException("Invalid Provider Type.");
}
}
}
파라미터로 넘어오는 ProviderType에 따라 해당 Provider의 userinfo를 범용 form에 맞춰 반환함.
- JwtUserDetails
package com.frombooktobook.frombooktobookbackend.security;
import com.frombooktobook.frombooktobookbackend.domain.user.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.*;
public class JwtUserDetails implements UserDetails, OAuth2User {
private Long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
private Map<String,Object> attributes;
public JwtUserDetails(Long id, String email, String password, Collection<? extends GrantedAuthority> authorities) {
this.id=id;
this.email=email;
this.password=password;
this.authorities=authorities;
}
// UserDetails : Form 로그인 시 사용
public static JwtUserDetails create(User user) {
List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
return new JwtUserDetails(
user.getId(),
user.getEmail(),
user.getPassword(),
authorities
);
}
public static JwtUserDetails create(User user, Map<String,Object> attributes) {
JwtUserDetails jwtUserDetails = JwtUserDetails.create(user);
jwtUserDetails.setAttributes(attributes);
return jwtUserDetails;
}
/**
* UserDetails 구현
*
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
// 일단 password는 lastName으로 설정
@Override
public String getPassword() {
return password;
}
// PK값 반환
@Override
public String getUsername() {
return email;
}
// 계정 만료 여부
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정 잠김 여부
@Override
public boolean isAccountNonLocked() {
return true;
}
// 계정 비밀번호 만료 여부
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정 활성화 여부
@Override
public boolean isEnabled() {
return true;
}
/**
* OAuth2User 구현
*
*/
@Override
public Map<String,Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return String.valueOf(id);
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
}
spring security에서 사용자 정보를 담는 인터페이스인 UserDetails와 OAuth2 로그인시 사용자 정보를 담는 인터페이스인 OAuth2User를 구현한 구현체.
Util
- CookieUtils
package com.frombooktobook.frombooktobookbackend.util;
import org.springframework.util.SerializationUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Base64;
import java.util.Optional;
public class CookieUtils {
// request에서 키가 name인 쿠키 가져오기
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if(cookies!=null && cookies.length>0) {
for(Cookie cookie : cookies) {
if(cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
// response에 키가 name, 값이 value 유지시간이 maxAge인 쿠키 추가
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name,value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
// response에서 키가 name인 쿠키 삭제
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie: cookies) {
if (cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
// 인코딩 => 암호화하기
public static String serialize(Object object) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(object));
}
// 디코딩 => 암호화 풀기
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
- TokenProvider
package com.frombooktobook.frombooktobookbackend.security.jwt;
import com.frombooktobook.frombooktobookbackend.config.AppProperties;
import com.frombooktobook.frombooktobookbackend.security.JwtUserDetails;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.Date;
@RequiredArgsConstructor
@Service
public class TokenProvider {
private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static long TOKEN_EXPIRE_MSEC = 864000000;
private static String TOKEN_SECRET_KEY = "lskdjfiawjfojals286k2345flkasdncvjknawoe3234ifjsfjalwejf";
private AppProperties appProperties;
public TokenProvider (AppProperties appProperties) {
this.appProperties = appProperties;
}
public String createToken(Authentication authentication) {
JwtUserDetails jwtUserDetails = (JwtUserDetails) authentication.getPrincipal();
Date now = new Date();
Date expireDate = new Date(now.getTime() + TOKEN_EXPIRE_MSEC);
return Jwts.builder()
.setSubject(jwtUserDetails.getId().toString())
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS256, TOKEN_SECRET_KEY)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = parseClaims(token);
return Long.parseLong(claims.getSubject());
}
// 토큰 정보 검증
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(TOKEN_SECRET_KEY).parseClaimsJws(authToken);
return true;
} catch (SignatureException ex) {
logger.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
logger.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
logger.error("JWT claims string is empty.");
}
return false;
}
private Claims parseClaims(String token) {
Claims claims = Jwts.parser()
.setSigningKey(TOKEN_SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims;
}
}
내가 application.properties를 잘못 적어놓은건지 모르겠는데 appProperties.get___ 를 하니까 자꾸 오류가 발생해서 그냥 일단 시크릿키랑 토큰 만료 기간을 final로 클래스 안에 설정해놓았다. 이건 나중에 리팩토링 하면서 고쳐야할 것 같다.
인증 로직 내에 있는 클래스들
- HttpCookieOAuth2AuthorizationRequestRepository
package com.frombooktobook.frombooktobookbackend.security;
import com.frombooktobook.frombooktobookbackend.util.CookieUtils;
import com.nimbusds.oauth2.sdk.util.StringUtils;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
// 인증 요청을 쿠키에 저장하고 검색
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie->CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
OAuth2AuthorizationRequest와 redirect_uri를 short-live cookie에 담아서 저장해두는 작업을 수행한다.
=> 이후 request의 state parameter 비교를 위해 필요함
- CustomUserDetailsService
package com.frombooktobook.frombooktobookbackend.security;
import com.frombooktobook.frombooktobookbackend.domain.user.UserRepository;
import com.frombooktobook.frombooktobookbackend.domain.user.User;
import com.frombooktobook.frombooktobookbackend.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException{
User user = userRepository.findByEmail(email)
.orElseThrow(()-> new UsernameNotFoundException("User not found with email "+email));
return JwtUserDetails.create(user);
}
@Transactional
public UserDetails loadUserById(Long id) throws ResourceNotFoundException{
User user = userRepository.findById(id)
.orElseThrow(()-> new ResourceNotFoundException("User","id",id));
return JwtUserDetails.create(user);
}
}
요청 시 들어온 username에 해당하는 user가 데이터베이스 내에 존재하는지 검증하고, 해당 user를 userDetails 형태로 반환하는 역할을 한다.
form 로그인 시 UsernamePasswordAuthenticationFilter가 실행되면서 필터 로직 안에서 실행된다.
- CustionOauth2UserService
package com.frombooktobook.frombooktobookbackend.security;
import com.frombooktobook.frombooktobookbackend.domain.user.User;
import com.frombooktobook.frombooktobookbackend.domain.user.UserRepository;
import com.frombooktobook.frombooktobookbackend.domain.user.ProviderType;
import com.frombooktobook.frombooktobookbackend.domain.user.Role;
import com.frombooktobook.frombooktobookbackend.security.user.OAuth2UserInfo;
import com.frombooktobook.frombooktobookbackend.security.user.OAuth2UserInfoFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.security.core.AuthenticationException;
import java.util.Optional;
/*
Oauth2 공급자로부터 액세스 토큰을 얻은 후 실행될 클래스.
OAuth2 제공 업체에서 받아온 사용자의 세부 정보를 처음으로 fetch.
로그인시 사용자 정보로 서버에 관련해서 해야하는 일들 수행
*/
@Service
@RequiredArgsConstructor
public class CustomOauth2UserService extends DefaultOAuth2UserService {
// 동일한 이메일을 사용하는 사용자가 이미 db 내에 있으면 세부 정보를 업데이트 하고, 그렇지 않으면 새 사용자를 등록한다.
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("attributes"+ super.loadUser(userRequest).getAttributes());
OAuth2User user = super.loadUser(userRequest);
try{
return process(userRequest, user);
} catch (AuthenticationException e) {
throw new OAuth2AuthenticationException(e.getMessage());
} catch (Exception e) {
throw new InternalAuthenticationServiceException(e.getMessage(),e.getCause());
}
}
// 인증을 요청하는 사용자가 존재하지 않다면 회원가입, 존재한다면 업데이트 실행
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, oAuth2User.getAttributes());
User savedUser = userRepository.findByEmail(userInfo.getEmail()).orElse(null);
// 이미 회원가입 된 사용자라면
if(savedUser!=null) {
System.out.println("이미 회원가입 된 사용자입니다.");
if(providerType != savedUser.getProviderType()) {
// OAuthProviderMissMatchException 만들어서 에러 띄우기
}
savedUser = updateUser(savedUser, userInfo);
} else {
// 회원가입 되어 있지 않은 사용자
System.out.println("신규 사용자입니다.");
savedUser = registerUser(userInfo, providerType);
}
return JwtUserDetails.create(savedUser,oAuth2User.getAttributes());
}
// 회원가입 진행
private User registerUser(OAuth2UserInfo userInfo, ProviderType providerType) {
return userRepository.save(
User.builder()
.email(userInfo.getEmail())
.name(userInfo.getName())
.role(Role.USER)
.imgUrl(userInfo.getImageUrl())
.providerType(providerType)
.build());
}
// 업데이트
private User updateUser(User user, OAuth2UserInfo userInfo) {
if(userInfo.getName()!=null && !user.getName().equals(userInfo.getName())) {
user.setName(userInfo.getName());
}
if(userInfo.getImageUrl()!=null && !user.getImgUrl().equals(userInfo.getImageUrl())) {
user.setImgUrl(userInfo.getImageUrl());
}
return user;
}
}
OAuth2 공급자로부터 code <-> access token 교환이 완료된 후 실행되는 서비스 클래스이다.
어플리케이션의 데이터베이스에 사용자 정보를 검색하고 존재할 시 업데이트 진행 (로그인), 존재하지 않을 시 데이터베이스에 저장한다. (회원가입).
- OAuth2AuthenticationSuccessHandler
package com.frombooktobook.frombooktobookbackend.security;
import com.frombooktobook.frombooktobookbackend.config.AppProperties;
import com.frombooktobook.frombooktobookbackend.exception.BadRequestException;
import com.frombooktobook.frombooktobookbackend.security.jwt.TokenProvider;
import com.frombooktobook.frombooktobookbackend.util.CookieUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
import static com.frombooktobook.frombooktobookbackend.security.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
/*
oauth2 인증이 성공적으로 완료된 뒤 사용되는 클래스
*/
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final AppProperties appProperties;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if(response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to" + targetUrl);
return;
}
clearAuthenticationAttributes(request,response);
// 리다이렉션 시킨다.
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// 최종 리다이렉션 시킬 Url을 작성하는 메소드. token도 포함시킨다.
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtils.getCookie(request,REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("Sorry! we've got an Unauthorized Redirect URI and can't proceed with the authentication");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
// 토큰 만들어서 redirect_uri 에 쿼리스트링과 함께 보낸다.
String token = tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token",token)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request,response);
}
// 올바른 Redirect url 요청인치 확인하는 메소드
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
return appProperties.getOauth2().getAuthorizedRedirectUris()
.stream()
.anyMatch(authorizedRedirectUri->{
// host 랑 port만 비교함. appProperties에 설정해 놓은 리다이렉션 url와 일치하는지 확인
URI authorizedURI = URI.create(authorizedRedirectUri);
if(authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedURI.getPort() == clientRedirectUri.getPort()) {
return true;
}
return false;
});
}
}
말 그대로 OAuth2 인증이 전부 성공적으로 끝났을 경우 실행되는 클래스.
tokenProvider로 jwt 토큰을 만들어내고 http request에서 요청한 redirect_uri에 jwt 토큰을 담아 보낸다.
- OAuth2AuthenticationFailureHandler
package com.frombooktobook.frombooktobookbackend.security;
import com.frombooktobook.frombooktobookbackend.util.CookieUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import static com.frombooktobook.frombooktobookbackend.security.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
/*
oauth2 인증 중 에러가 발생하여 인증이 실패하면 실행될 클래스
*/
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse("/");
// 에러 메세지를 쿼리스트링에 포함시켜 보낸다.
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error",exception.getLocalizedMessage())
.build().toUriString();
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request,response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
OAuth2 인증 중 인증에 실패하거나 오류가 발생했을 시 실행되는 클래스.
- TokenAuthenticationFilter
package com.frombooktobook.frombooktobookbackend.security.jwt;
import com.frombooktobook.frombooktobookbackend.security.CustomUserDetailsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/*
요청으로부터 JWT authentication token을 읽고 검증한 뒤
만약 토큰이 유효할 경우 SecurityContext에 넣는다.
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
private static final Logger logger = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
// 토큰이 존재하며, 유효한 경우 수동으로 인증 설정
if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Long userId =tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails = customUserDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Context에 인증을 설정하여 현재 사용자가 인증 되도록 함 => Spring Security 설정 성공으로 넘어감.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Could not set user authentication in security context", e);
}
// jwt 없을 경우 (ex: 첫 로그인인 경우) 기존 로직 실행
filterChain.doFilter(request,response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
System.out.println(bearerToken.substring(7,bearerToken.length()));
return bearerToken.substring(7,bearerToken.length());
}
return null;
}
}
보통은 사용자의 최초 로그인이 성공적으로 수행됐을시, 사용자의 인증 정보를 session의 SecurityContext에 넣어둔다.
그 이후 요청이 들어오면 UsernamePasswordAuthenticationFilter 보다 먼저 SecurityContextPersistenceFilter가 수행되어 session에 들어온 요청 정보와 동일한 authentication 정보가 있는지 확인하고 존재할 경우에는 usernamePasswordAuthenticationFilter를 실행하지 않고, session에 존재하는 authenticaion을 꺼내어 SecurityCotextHolder에 넣는다고 한다.
즉, 로그인 이후에는 요청마다 또 인증을 하지 않고 기존에 로그인 된 정보를 활용하여 인증할 수 있게끔 만들어 주는 Filter가 SecurityContextPersistenceFilter이다. 하지만 나는 session을 사용하지 않고 JWT를 사용하므로 토큰을 통해 이 기능을 수행하는데, 이 클래스가 그 기능의 구현체이다.
request에 유효한 jwt가 있는지 확인하고, 없을 경우에는 filter chain의 다음 필터를 실행시킨다.
만약 유효한 jwt가 있는 경우에는 userDetailsService를 바로 실행시켜 인증을 빠르게 수행한다.
- JwtAuthenticationEntryPoint
package com.frombooktobook.frombooktobookbackend.security.jwt;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException)
throws IOException {
// 유효한 유저 정보 없이 접근시 401 응답
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
System.out.println("401 error occured.");
}
}
Http request의 인증에 실패했을 경우를 처리하는 클래스이다.
401 응답을 보낸다.
- JwtAccessDeniedHandler
package com.frombooktobook.frombooktobookbackend.security.jwt;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest reauest, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException, ServletException {
// 유저 정보는 있으나 필요한 권한이 없을 경우 403 응답
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
Http request의 인증에는 성공했으나 인가에는 실패한 경우를 처리하는 클래스이다.
403 응답을 보낸다.
- CurrentUser
package com.frombooktobook.frombooktobookbackend.security;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import java.lang.annotation.*;
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
}
이 인터페이스 구현 뒤 controller의 파라미터에 @CurrentUser을 달아주면 현재 인증된 사용자의 userDetails을 할당해준다.
Java의 어노테이션은 크게 built-in / meta 로 나뉜다.
[ built-in annotation ]
- java 코드에 적용되는 어노테이션
- @Override,@Deprecated 등등
[ meta annotation ]
- 다른 어노테이션에 적용되기 위한 어노테이션
- @Retention, @Documented, @Target, @Inherited 등등
[ Meta annotation 종류 ]
- Retention : 해당 어노테이션의 정보를 어느 범위까지 유지할 것인지를 설정
-- SOURCE : 컴파일 전까지만 유효하며 컴파일 이후에 사라짐
-- CLASS : 컴파일러가 클래스를 참조할 때까지 유효
-- RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조 가능
- Documented : JavaDoc 생성 시 Document에 포함되도록 함
- Targer : 해당 어노테이션이 사용되는 위치 결정
-- PACKAGE : 패키지 선언시
-- TYPE : 타입 선언시
-- CONSTRUCTOR : 생성자 선언시
-- FIELD : 멤버 변수 선언시
-- PARAMETER : 매개 변수 타입 선언 시
등등 ...
- Inherited : 해당 어노테이션을 하위 클래스에 적용함
- Repeatable : Java8부터 지원, 연속적으로 어노테이션을 선언하는 것을 허용함
-AuthenticationPrincipal : useDetailsService에서 return한 객체를 파라미터로 직접 받을 수 있음.
Controller
- authController
package com.frombooktobook.frombooktobookbackend.controller.auth;
import com.frombooktobook.frombooktobookbackend.domain.user.ProviderType;
import com.frombooktobook.frombooktobookbackend.domain.user.Role;
import com.frombooktobook.frombooktobookbackend.domain.user.User;
import com.frombooktobook.frombooktobookbackend.domain.user.UserRepository;
import com.frombooktobook.frombooktobookbackend.exception.BadRequestException;
import com.frombooktobook.frombooktobookbackend.security.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
@RequiredArgsConstructor
@RequestMapping("/auth")
@RestController
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
@PostMapping("/login")
public ResponseEntity<AuthResponseDto> authenticateUser(@RequestBody LoginRequestDto loginRequest) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword()
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
User user;
if(!userRepository.existsByEmail(loginRequest.getEmail())){
throw new BadRequestException("존재하지 않는 이메일 계정입니다.");
} else {
user = userRepository.findByEmail(loginRequest.getEmail()).orElse(null);
}
String token = tokenProvider.createToken(authentication);
return ResponseEntity.ok(new AuthResponseDto(token,user.getName(),user.getEmail()));
}
@PostMapping("/register")
public ResponseEntity<?> registerUser(@RequestBody RegisterRequestDto requestDto) {
if(userRepository.existsByEmail(requestDto.getEmail())) {
throw new BadRequestException("이미 가입된 이메일입니다.");
}
User user = userRepository.save(
User.builder()
.email(requestDto.getEmail())
.name(requestDto.getName())
.password(passwordEncoder.encode(requestDto.getPassword()))
.providerType(ProviderType.LOCAL)
.role(Role.USER)
.build());
URI location = ServletUriComponentsBuilder
.fromCurrentContextPath().path("/user/me")
.buildAndExpand(user.getId()).toUri();
return ResponseEntity.created(location)
.body(new ApiResponseDto(true, "User registered successfully ! "));
}
}
리팩토링하면서 대부분의 로직을 userService에 옮겨야할 것 같다.
- userController
package com.frombooktobook.frombooktobookbackend.controller.user;
import com.frombooktobook.frombooktobookbackend.domain.user.User;
import com.frombooktobook.frombooktobookbackend.security.CurrentUser;
import com.frombooktobook.frombooktobookbackend.security.JwtUserDetails;
import com.frombooktobook.frombooktobookbackend.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/me")
public User getCurrentUser(@CurrentUser JwtUserDetails userDetails) {
return userService.getCurrentUser(userDetails.getId());
}
}
Dto
- ApiResponseDto
package com.frombooktobook.frombooktobookbackend.controller.auth;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class ApiResponseDto {
private boolean success;
private String message;
public ApiResponseDto(boolean success, String message) {
this.success = success;
this.message = message;
}
}
- LoginRequestDto
package com.frombooktobook.frombooktobookbackend.controller.auth;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
@Getter
@Setter
@NoArgsConstructor
public class LoginRequestDto {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
@Builder
public LoginRequestDto(String email,String password) {
this.email=email;
this.password=password;
}
}
- AuthResponseDto
package com.frombooktobook.frombooktobookbackend.controller.auth;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class AuthResponseDto {
private String name;
private String email;
private String accessToken;
private String tokenType="Bearer";
public AuthResponseDto(String accessToken, String name, String email) {
this.accessToken = accessToken;
this.name = name;
this.email=email;
}
}
- RegisterRequestDto
package com.frombooktobook.frombooktobookbackend.controller.auth;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class RegisterRequestDto {
private String email;
private String name;
private String password;
@Builder
public RegisterRequestDto(String email, String name, String password) {
this.email= email;
this.name=name;
this.password = password;
}
}
'Project > FromBookToBook' 카테고리의 다른 글
[FBTB] 4. 로그인 유지 기능 구현 (UI) (0) | 2022.04.22 |
---|---|
[FBTB] 4. 로그인 유지 기능 구현 [1] (0) | 2022.04.21 |
(임시) user/password springSecurity 클래스들 (0) | 2022.04.12 |
[FBTB] 3. 로그인 기능 구현 (with Oauth2) (0) | 2022.04.07 |
[FBTB] 2. 독후감 목록 기능 구현 (0) | 2022.04.01 |