728x90
 

GitHub - da9dac/blank

Contribute to da9dac/blank development by creating an account on GitHub.

github.com

전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.

 

 

환경 설정

가장 많이 사용되는 OAuth2.0 서비스 제공업체인

구글, 카카오, 네이버를 이용한 인증을 구현하기 전에

yml / properties 파일에 설정을 추가해주겠다.

 

각 API 사용을 위한 사전 설정은 구글링 혹은 아래의 사이트를 참고하길 바란다.

OAuth 2.0을 사용하여 Google API에 액세스하기  |  Authorization  |  Google for Developers

 

OAuth 2.0을 사용하여 Google API에 액세스하기  |  Authorization  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English 의견 보내기 OAuth 2.0을 사용하여 Google API에 액세스하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하

developers.google.com

REST API | Kakao Developers 문서

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

네이버 로그인 API 명세 - LOGIN (naver.com)

 

네이버 로그인 API 명세 - LOGIN

네이버 로그인 API 명세 네이버 로그인 API는 네이버 로그인 인증 요청 API, 접근 토큰 발급/갱신/삭제 요청API로 구성되어 있습니다. 네이버 로그인 인증 요청 API는 여러분의 웹 또는 앱에 네이버

developers.naver.com

 

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: your-client-id
            client-secret: your-client-secret
            scope: email
            redirect-uri: http://localhost:8080/auth/oauth/code
          kakao:
            client-id: your-client-id
            client-secret: your-client-secret
            authorization-grant-type: authorization_code
            scope: account_email
            client-name: kakao
            client-authentication-method: POST
            redirect-uri: http://localhost:8080/auth/oauth/code
          naver:
            client-id: your-client-id
            client-secret: your-client-secret
            authorization-grant-type: authorization_code
            client-name: Naver
            redirect-uri: http://localhost:8080/auth/oauth/code
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

 

사전 설정을 마친 후 각 API의

클라이언트 ID와 시크릿키를 다음과 같이 설정 파일에 추가해준다.

 

scope는 자신이 가져오고자 하는 정보들을 적어주면 되는데

간단하게 이메일 정도만 가져오도록 했다.

 

구글, 페이스북, 깃허브 같은 기업은 이미 등록이 되어있지만

국내 기업인 카카오와 네이버는 별도로 provider를 설정해줘야 한다.

 

리다이렉트 uri의 경우에는 본인이 설정해둔 곳으로 바꿔주면 되는데

로컬에서만 테스트 할거라 그냥 대충 적어놨다.

 

@Bean
public DefaultOAuth2UserService defaultOAuth2UserService() {
    return new DefaultOAuth2UserService();
}

@Bean
public RestTemplate restTemplate() {
    return new RestTemplate();
}

 

위와 같이 설정을 마친 후에 Config 클래스에

OAuth를 사용하기 위한 빈 등록들을 해준다.

 

OAuth 요청 처리 흐름

원래는 클라이언트가 로그인 요청을 보내면 서버에서 리다이렉트 URL을 넘겨준 후에

클라이언트가 다시 이동하여 로그인을 수행한 후에 인가 코드를 넘겨주지만

미리 리다이렉트 URL을 전달하여 통신 횟수를 줄이겠다.

 

  1. 클라이언트(프론트) 측에 미리 OAuth 리다이렉트 URL을 가르쳐준다.
  2. 클라이언트 측이 로그인 후 인가 코드를 얻어 서버에 전달한다.
  3. 서버는 인가 코드로 해당 서비스 제공자의 액세스 토큰을 얻는다.
  4. 액세스 토큰으로 사용자 정보를 획득한다.
  5. 사용자 정보로 로그인 혹은 회원가입을 진행한다.
  6. 실제 본인 서버의 액세스 및 리프래시 토큰을 발급한다.

 

OAuth 서비스 구현 사전 작업

@Getter
@Builder
public class MemberProfile {

    private String email;
}

 

scope에서 설정한 이메일을 담을 클래스를 하나 만든다.

(닉네임이나 다른 정보들도 더 가져오는 경우에는 필드를 추가하면 된다)

 

public enum OAuthProvider {

	private final String registrationId;
	private final Function<Map<String, Object>, MemberProfile> of;

	OAuthProvider(String registrationId, Function<Map<String, Object>, MemberProfile> of) {
		this.registrationId = registrationId;
		this.of = of;
	}
}

 

그 후 위와 같은 enum 클래스를 하나 생성하여

인증 서비스 제공자와 사용자의 정보를 가져오는 함수를 선언해준다.

 

GOOGLE("google", (attributes) ->
    MemberProfile.builder()
        .email((String) attributes.get("email"))
        .build()),

KAKAO("kakao", (attributes) -> {
    Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");

    return MemberProfile.builder()
        .email((String) kakaoAccount.get("email"))
        .build();
}),

NAVER("naver", (attributes) -> {
    Map<String, Object> response = (Map<String, Object>) attributes.get("response");

    return MemberProfile.builder()
        .email((String) response.get("email"))
        .build();
});

 

위와 같이 3개의 enum 객체를 만들어준다.

 

각 서비스마다 정보를 가져오는 방법이 다른데

구글, 페이스북, 깃허브 같은 경우는 속성명만으로 간편하게 가져올 수 있고,

카카오, 네이버의 경우에는 각각 kakao_account, response로

Map 형태의 속성 컬렉션을 가져온 후에

키를 사용해 속성 값을 가져올 수 있다.

 

public String getRegistrationId() {
    return this.registrationId;
}

 

위와 같은 메서드도 나중에 사용하기 위해 하나 만들어 둔다.

 

서비스 로직 구현

이제 실제 인증 로직을 처리할 서비스 로직을 구현하겠다.

 

구현할 로직들은 간단하게 아래와 같다.

  • Authoriztion Code를 통해 OAuth 서비스에 액세스 한다.
  • 사용자 정보를 획득한다.
  • 회원가입 혹은 로그인을 진행한다.
  • 액세스 토큰 및 리프래시 토큰을 발급한다.

 

@Service
@Transactional(readOnly = true)
public class OAuthService {
	private final InMemoryClientRegistrationRepository inMemoryRepository;
	private final MemberRepository memberRepository;
	private final JwtProvider jwtProvider;
	private final DefaultOAuth2UserService defaultOAuth2UserService;
	private final RestTemplate restTemplate;

	public OAuthService(InMemoryClientRegistrationRepository inMemoryRepository, MemberRepository memberRepository,
		JwtProvider jwtProvider, DefaultOAuth2UserService defaultOAuth2UserService, RestTemplate restTemplate) {
		this.inMemoryRepository = inMemoryRepository;
		this.memberRepository = memberRepository;
		this.jwtProvider = jwtProvider;
		this.defaultOAuth2UserService = defaultOAuth2UserService;
		this.restTemplate = restTemplate;
	}
}

 

위와 같은 서비스 클래스를 하나 만들어 주고 의존성을 주입 받는다.

 

각 클래스의 역할은 아래와 같다.

  • InMemoryClientRegistrationRepository : YML 파일에 설정했던 OAuth Provider 정보가 들어있는 저장소
  • MemberRepository : 기존 회원이면 로그인 정보를 확인하고 아닐 시 신규 회원 정보를 저장
  • JwtProvider : 로그인 성공 시 토큰을 발급해주는 역할
  • DefaultOAuth2UserService : OAuth 사용자 정보를 얻어오는 역할로 UserDetailsService와 같다 볼 수 있다.
  • RestTemplate : 인가 코드를 사용해 요청을 보내 액세스 토큰을 발급 받기 위해 사용

 

@Transactional
public OAuthResult login(OAuthProvider provider, String code) {

    String registrationId = provider.getDescription(); // 1

    ClientRegistration clientRegistration = inMemoryRepository.findByRegistrationId(registrationId); // 2

    String token = getToken(code, clientRegistration); // 3

    OAuth2User oAuth2User = getOAuth2User(token, clientRegistration); // 4

    Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes()); // 5

    MemberProfile memberProfile = OAuthProvider.extract(registrationId, attributes); // 6

    Member member = getOrSaveMember(memberProfile); // 7

    return createToken(member); // 8
}

 

전체적인 로직의 흐름은 위의 코드와 같은데 간단하게 살펴보겠다.

 

0. 컨트롤러에서 클라이언트로부터 받아온 Provider 정보와 인가 코드를 받아온다

1. Provider 정보에서 어떤 OAuth 서비스를 사용해 로그인할지 정보를 가져온다.

2. InMemoryClientRegistrationRepository에서 Provider의 상세 정보를 가져온다.

3. 구글, 네이버, 카카오 등의 서비스의 액세스 토큰을 얻어 온다.

4. 얻어온 토큰으로 사용자 정보를 얻어 온다. (UserDetails와 같다고 볼 수 있다)

5. 필요한 사용자의 정보만 Map 형태로 가져온다.

6. 사용자 정보에서 이메일을 가져와 MemberProfile 객체를 만든다.

7. 이미 존재하는 이메일이면 기존 회원 정보를, 아니라면 새로운 회원을 만든다.

8. 실제 자신의 백엔드 서버에서 사용될 액세스 및 리프래시 토큰을 생성해 리턴한다.

 

@Getter
@Builder
public class OAuthResult {
	
	private String accessToken;
	private String refreshToken;
}

 

우선 로그인 요청의 리턴값인 액세스 토큰과 리프래시 토큰을

저장할 클래스를 하나 생성해준다.

 

private String getToken(String code, ClientRegistration clientRegistration) {

    // 액세스 토큰 획들을 위해 요청을 보낼 URI
    String uri = clientRegistration.getProviderDetails().getTokenUri();

    // 요청 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(APPLICATION_FORM_URLENCODED);
    headers.setAcceptCharset(List.of(UTF_8));

    // 요청값 설정
    HttpEntity<MultiValueMap<String, String>> entity =
        new HttpEntity<>(tokenRequest(code, clientRegistration), headers);

    // 요청 전송
    try {
        ResponseEntity<Map<String, String>> responseEntity = restTemplate.exchange(
            uri,
            HttpMethod.POST,
            entity,
            new ParameterizedTypeReference<>() {}
        );

        // 응답 바디에서 액세스 토큰 획득
        return responseEntity.getBody().get("access_token");

    } catch (HttpClientErrorException.BadRequest e) {
        throw new RuntimeException();
    }
}

 

인가 코드로 액세스 토큰을 얻어 오는 메서드로

실제 백엔드 서버의 액세스 토큰이 아닌 OAuth 서비스를 이용하기 위한

액세스 토큰에 해당한다.

 

private MultiValueMap<String, String> tokenRequest(String code, ClientRegistration provider) {
    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();

    formData.add("code", code);
    formData.add("grant_type", "authorization_code");
    formData.add("redirect_uri", provider.getRedirectUri());
    formData.add("client_secret", provider.getClientSecret());
    formData.add("client_id",provider.getClientId());
    return formData;
}

 

인가 코드와 서비스 제공자의 정보를 토대로 요청에 사용될

데이터들을 설정해 준 후에

RestTemplate을 사용해 요청을 보내 액세스 토큰을 얻어 온다.

 

private OAuth2User getOAuth2User(String token, ClientRegistration clientRegistration) {

    OAuth2AccessTokenResponse tokenResponse = OAuth2AccessTokenResponse.withToken(token)
        .tokenType(OAuth2AccessToken.TokenType.BEARER)
        .expiresIn(3600L)
        .build();

    OAuth2UserRequest userRequest = new OAuth2UserRequest(clientRegistration, tokenResponse.getAccessToken());

    return defaultOAuth2UserService.loadUser(userRequest);
}

 

얻어온 액세스 토큰으로 OAuth2User 객체를 얻어오기 위한

응답 객체를 만든 후에 요청을 보내 객체를 얻어온다.

 

loadUser 메서드나 OAuth2User 객체명을 보면 알 수 있듯이

UserDetails를 UserDetailsService의 loadUserByUsername 메서드로

가져오는 것과 비슷하다.

 

private Member getOrSaveMember(MemberProfile memberProfile) {
    Member member = getMember(memberProfile);
    if (member == null) {
        member = saveMember(memberProfile);
    }
    return member;
}

 

OAuth2User 객체에서 얻어온 이메일 값으로

이미 해당 이메일로 가입된 회원이 있으면

굳이 회원가입을 할 필요가 없으니 그대로 기존 회원 정보를 리턴하고

가입된 회원이 아니라면 새로 가입 시킨 후 리턴한다.

 

private Member getMember(MemberProfile memberProfile) {
    return memberRepository.findByEmail(memberProfile.getEmail())
        .orElse(null);
}

private Member saveMember(MemberProfile memberProfile) {
    Member member = Member.createMember(
        memberProfile.getEmail(),
        memberProfile.getEmail().split("@")[0],
        generateRandomNickname());

    return memberRepository.save(member);
}

 

회원 조회 및 회원 가입 메서드는 대충 위와 같이 짤 수 있다.

(랜덤 닉네임을 부여하였는데 이 부분들은 각자 편한대로 바꿔도 상관 없다)

 

private OAuthResult createToken(Member member) {
    CustomUserDetails userDetails = new CustomUserDetails(member);

    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

    String accessToken = jwtProvider.createAccessToken(authentication, AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);
    String refreshToken = jwtProvider.createRefreshToken(authentication, AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);

    return OAuthResult.builder()
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
}

 

기존에 JWT 인증 필터에서 토큰을 생성하던 방식처럼 그대로

인증 객체를 하나 만든 후에 이를 토대로 토큰을 생성해준다.

 

OAuth 컨트롤러 구현

@RestController
@RequestMapping("/auth")
public class AuthController {

	private MemberRepository memberRepository;
	private PasswordEncoder passwordEncoder;
	private OAuthService oAuthService;

	public AuthController(MemberRepository memberRepository, PasswordEncoder passwordEncoder,
		OAuthService oAuthService) {
		this.memberRepository = memberRepository;
		this.passwordEncoder = passwordEncoder;
		this.oAuthService = oAuthService;
	}
}

 

기존 컨트롤러에 OAuthService 클래스 의존성을 추가해준다.

 

@Getter
public class OAuthRequest {
	private OAuthProvider provider;
	private String code;
}

 

Provider와 인가 코드를 받아올 DTO 클래스도 위와 같이 생성해준다.

 

@PostMapping("/oauth")
public ResponseEntity<Void> oauth(@RequestBody @Valid OAuthRequest request) {
    OAuthResult token = oAuthService.login(request.getProvider(), request.getCode());

    HttpHeaders headers = new HttpHeaders();
    headers.add(AUTHORIZATION, BEARER + token.getAccessToken());
    headers.add(REFRESH, BEARER + token.getRefreshToken());

    return ResponseEntity.ok().headers(headers).build();
}

 

구현해두었던 login 메서드로 Provider와 인가 코드를 넘겨준 후에

액세스 토큰과 리프래시 토큰을 받아온다.

 

이를 응답의 헤더에 추가하여 클라이언트에 전달해주면 끝이다.

(응답의 바디로 주든 헤더로 주든 편한 방식대로 해도 괜찮다)

 

인가 코드 얻기 및 테스트

구글 

https://accounts.google.com/o/oauth2/v2/auth?client_id={클라이언트ID}&redirect_uri={리다이렉트URI}&response_type=code&scope={가져올 스코프}

 

카카오

https://kauth.kakao.com/oauth/authorize?client_id={클라이언트ID}&scope={가져올 스코프}&response_type=code&redirect_uri={리다이렉트 URI}

 

네이버

https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id={클라이언트ID}&redirect_uri={리다이렉트 URI}&state=true

 

위와 같은 로그인 주소를 클라이언트 측에 미리 전달해주어

인가 코드와 Provider만 전달 받으면 된다.

 

각 URI 형식에 맞게 값을 넣은 후에 로그인을 하여 인가 코드를 얻어보겠다.

 

@GetMapping("/oauth/code")
public String code() {
    return "get code";
}

 

카카오 같은 경우는 존재하지 않는 페이지를 리다이렉트 URI로 지정하면

응답으로 전달한 코드를 만료시키기 때문에 아무 API나 임시로 만들어 두는 것이 좋다.

 

 

구글의 경우에는 이렇게 리다이렉트된 URI를 확인해보면 주소창에 인가 코드가 적힌 것을 확인할 수 있다.

 

 

네이버와 카카오도 마찬가지로 이렇게 인가 코드를 확인할 수 있고

이 코드를 oauth 로그인 api로 요청을 보내면

 

 

이렇게 로그인에 성공한 후에 액세스 및 리프래시 토큰을 발급해주는 것을 확인할 수 있다.

 

마치며

JWT 및 OAuth 인증 방식의 경우에는

항상 돌아가는 방식이 비슷하기 때문에

어떤 순서로 인증이 진행되는지에 대해서만

확실하게 알아두면 언제든 구현할 수 있다.

 

로직이 바뀔 일이 거의 없기 때문에

암기 과목에 가까운 느낌이 들기도...

 

여담으로 깃허브 인증의 경우에는 사용자 정보를 가져오는

방식이 조금 다른 것으로 알고 있다.

 

이메일 속성만 가져오는 과정이 달랐는지

다른 속성들도 모두 달랐는지 햇갈리는데

만약 깃허브 인증을 사용한다면 참고하길 바란다.

728x90
 

GitHub - da9dac/blank

Contribute to da9dac/blank development by creating an account on GitHub.

github.com

전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.

 

 

이번 게시글에서는

액세스 토큰과 리프래시 토큰 발급,

리프래시 토큰을 이용한 액세스 토큰 재발급,

액세스 토큰 검증

이렇게 3개의 필터를 구현하겠다.

 

로그인 처리 및 토큰 발급 필터

사용자의 로그인 요청을 받아

서버에 저장된 인증 정보와 일치하는지 판단하여

사용자의 로그인을 처리하고

성공 시 액세스 토큰과 리프래시 토큰을 발급하는

필터를 구현하겠다.

 

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	private final JwtProvider jwtProvider;
	private final AuthenticationManager authenticationManager;

	public JwtAuthenticationFilter(JwtProvider jwtProvider, AuthenticationManager authenticationManager1) {
		this.jwtProvider = jwtProvider;
		this.authenticationManager = authenticationManager1;
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 
		throws AuthenticationException {

		return null;
	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, 
		FilterChain chain, Authentication authResult) throws IOException, ServletException {

		
	}
}

 

우선 UsernamePasswordAuthenticationFilter를 상속받는 필터 클래스를 만들어준다.

 

토큰을 발급받기 위해 JwtProvider와

인증을 수행하기 위한

AuthenticationManager 객체를 DI 받는다.

 

attemptAuthentication와 successfulAuthentication 메서드를

오버라이딩 하여 인증을 처리하고 토큰을 발급하는

로직을 추가해주면 된다.

 

@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

    if (!request.getMethod().equals("POST")) {
        // POST 메서드로만 로그인 요청을 받고 싶은 경우
        // 다른 메서드로 들어온 요청일 시 예외를 발생
    }

    try {
        JwtLogin loginDto = getLoginDtoFrom(request);

        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken);

    }  catch (Exception exception) {
        return null;
    }
}

 

메서드 명에서 알 수 있듯이

인증을 시도하는 기능을 수행하는 메서드다.

 

로그인 요청을 POST 메서드만 허용하고 싶다면

위와 같이 설정하면 되고

모두 허용할거라면 넘어가도 상관없다.

 

로그인 요청 객체의 정보로

미인증 상태의 UsernamePasswordAuthenticationToken을 생성하고

이를 AuthenticationManager에 전달한다.

 

인증에 성공하면 인증 상태의 토큰을 리턴하고

인증에 실패하면 예외가 발생할 것이다.

(발생한 예외는 try - catch 문으로 처리해 준다)

 

지금은 코드를 설명하기 위해

예외 처리를 대충 넘어가지만

어떤 이유로 인증에 실패했는지

자세하게 처리하는 것이 좋다.

 

private JwtLogin getLoginDtoFrom(HttpServletRequest request) throws IOException {
    return new ObjectMapper().readValue(request.getInputStream(), JwtLogin.class);
}

 

요청 객체에서 로그인 요청 데이터를 얻어올 수 있다.

 

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
    FilterChain chain, Authentication authResult) throws IOException {

    String accessToken = jwtProvider.createAccessToken(authResult, ACCESS_TOKEN_EXPIRE_TIME);
    String refreshToken = jwtProvider.createRefreshToken(authResult, REFRESH_TOKEN_EXPIRE_TIME);

    response.setHeader(AUTHORIZATION, BEARER + accessToken);
    response.setHeader(REFRESH, BEARER + refreshToken);

    response.setStatus(HttpServletResponse.SC_OK);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding("UTF-8");
    response.getWriter().write("로그인 성공");
}

 

마지막으로 인증에 성공한 경우 자동으로 실행될 메서드를 구현한다.

 

인증에 성공했으니 응답 헤더에 포함할

액세스 토큰과 리프래시 토큰을 생성하고

이를 응답의 헤더에 지정해 준다.

 

토큰의 형태는 "Bearer abcde12345"처럼 생겼기 때문에

꼭 Bearer을 붙여줘야 한다.

 

헷갈릴 위험이 있기 때문에 이런 값들은

상수에 저장해 두는 것이 좋다.

 

이제 로그인에 성공하면 이 메서드에서 설정한

응답들이 헤더와 바디에 표시될 것이다.

 

회원가입 구현 및 로그인 테스트

로그인 요청을 보내기 위해 간단하게 회원가입 기능을 구현하겠다.

 

@RestController
@RequestMapping("/auth")
public class AuthController {

	private MemberRepository memberRepository;
	private PasswordEncoder passwordEncoder;

	public AuthController(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
		this.memberRepository = memberRepository;
		this.passwordEncoder = passwordEncoder;
	}

	@PostMapping("/signup")
	public ResponseEntity<Void> signup() {
		memberRepository.save(
			Member.builder()
				.email("test@email.com")
				.password(passwordEncoder.encode("qwer1234!"))
				.authority(Authority.ROLE_USER)
				.build()
		);

		return new ResponseEntity<>(HttpStatus.CREATED);
	}
}

 

원래는 서비스 로직이라 컨트롤러단에 두면 안 되지만

빠르게 로그인 테스트만 하기 위해

컨트롤러에서 처리하게 했다.

 

 

요청을 보낸 후에 로그인을 해보면

위와 같이 액세스 토큰과 리프래시 토큰이

헤더에 담겨 있는 걸 확인할 수 있다.

 

액세스 토큰 재발급 구현

액세스 토큰이 만료된 경우

리프래시 토큰으로 재발급받는 방법은

간단하게 두 가지 방법이 있다.

 

첫 번째는 클라이언트에게 매 요청마다

액세스 토큰과 리프래시 토큰을 모두 받아

액세스 토큰이 만료된 경우

자동으로 리프래시 토큰을 발급해 주는 방법이다.

 

두 번째는 요청에 필요한 액세스 토큰만 받고

만료된 액세스 토큰인 경우

클라이언트에게 리프래시 토큰을 요청한다.

 

이번에는 두 번째  방법을 사용해 보겠다.

 

public class JwtRefreshFilter extends OncePerRequestFilter {

	private JwtProvider jwtProvider;

	public JwtRefreshFilter(JwtProvider jwtProvider) {
		this.jwtProvider = jwtProvider;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

	}
}

 

위와 같이

OncePerRequestFilter를 상속받는 필터를 만들어준다.

 

doFilterInternal 메서드에

리프래시 토큰으로 액세스 토큰을 발급해주는

로직을 구현해 주면 된다.

 

private String getRefreshToken(HttpServletRequest request) {

    String refreshToken = request.getHeader(REFRESH);

    if (refreshToken == null) {
        throw new JwtNotFoundException();
    }

    return refreshToken.replace(BEARER, "");
}

 

우선 사용자가 보낸 요청 헤더에서

리프래시 토큰을 얻어 온다.

 

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
    FilterChain filterChain) throws ServletException, IOException {

    if(!request.getMethod().equals("POST")){
        // 특정 메서드로만 요청 받고 싶은 경우
    }
    else{
        try {
            String refreshToken = getRefreshToken(request);

            jwtProvider.validateToken(refreshToken);

            String refilledAccessToken =
                jwtProvider.refillAccessToken(refreshToken, ACCESS_TOKEN_EXPIRE_TIME);

            response.setHeader(AUTHORIZATION, BEARER + refilledAccessToken);
        } catch (Exception exception) {
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "알 수 없는 오류입니다. 다시 시도해주세요.");
        }
    }
}

 

그 후에 이전에 구현했던 인증 필터와 비슷한 구조로

실질적인 필터의 로직을 구현해 준다.

 

헤더에서 가져온 리프래시 토큰이

올바른 토큰인지 검증한 후에

올바르다면 액세스 토큰을 새로 발급받아

응답의 헤더에 지정해 준다.

 

이 과정에서 발생하는 예외는

계속 언급하지만 좀 더 세세하게 처리해 주는 것이 좋다.

 

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {

    return !request.getRequestURI().equals(REFRESH_URL);
}

 

추가로 shouldNotFilter 메서드도 오버라이딩 하여

특정 주소의 요청만 허용하게 해 주겠다.

(리프래시 요청을 받고 싶은 URL을 지정해 주면 된다)

 

액세스 토큰 재발급 테스트

이제 리프래시 토큰을 보내

액세스 토큰이 재발급되는지 확인해 보겠다.

 

 

액세스 토큰만 재발급하는 작업에 성공했다.

 

액세스 토큰 검증 구현

이제 토큰을 발급하는 필터들의 구현은 모두 끝났으니

인증 및 인가가 필요한 요청에 대해

요청에 포함된 액세스 토큰을 검증하고

사용자의 권한이 충분한지

검증하는 필터를 구현하겠다.

 

public class JwtVerificationFilter extends OncePerRequestFilter {
	
	private final JwtProvider jwtProvider;
	private final CustomUserDetailsService customUserDetailsService;

	public JwtVerificationFilter(JwtProvider jwtProvider, CustomUserDetailsService customUserDetailsService) {
		this.jwtProvider = jwtProvider;
		this.customUserDetailsService = customUserDetailsService;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		
	}

	@Override
	protected boolean shouldNotFilter(HttpServletRequest request) {
		
	}
}

 

검증 필터도 마찬가지로

OncePerRequestFilter를 상속받아 구현하겠다.

 

해당 필터는 토큰을 생성하는 것이 아닌

검증만 수행하면 된다.

 

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    try{
        Claims claims = verifyClaims(request);
        setAuthenticationToContext(claims);
    } catch(Exception e){
        request.setAttribute(EXCEPTION, e);
    }

    filterChain.doFilter(request, response);
}

 

해당 필터는 위와 같이 토큰의 Claim만 검증하면

올바른 토큰인걸 확인할 수 있으니

검증이 통과하면 시큐리티 콘텍스트에

인증 정보를 등록해 주면 된다.

 

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {

    String accessToken = getAccessToken(request);

    return accessToken == null || !accessToken.startsWith(BEARER);
}

 

요청 헤더에 액세스 토큰이 포함되지 않은 경우는

해당 메서드에서 처리해 준다.

 

private Claims verifyClaims(HttpServletRequest request) {

    String accessToken = getAccessToken(request).replace(BEARER, "");

    return jwtProvider.getClaims(accessToken);
}

 

Claim 검증은 위와 같이

기존에 JwtProvider 클래스에 구현해 둔

토큰 검증 메서드를 사용한다.

 

private String getAccessToken(HttpServletRequest request) {

    return request.getHeader(AUTHORIZATION);
}

 

액세스 토큰은 위와 같이

요청의 헤더에서 간단하게 가져올 수 있다.

 

private void setAuthenticationToContext(Claims claims) {

    CustomUserDetails principal =
        (CustomUserDetails) customUserDetailsService.loadUserByUsername(claims.getSubject());

    Collection<? extends GrantedAuthority> authorities = principal.getAuthorities();

    Authentication authentication =
        new UsernamePasswordAuthenticationToken(principal, null, authorities);

    SecurityContextHolder.getContext().setAuthentication(authentication);
}

 

콘텍스트에 인증 정보를 저장하는 메서드로

토큰의 Claim에서 사용자의 이메일을 가져와

CustomUserDetails를 얻어온 후에

이를 이용해 해당 사용자의 권한을 포함한

인증 객체를 만들고 컨텍스트에 저장한다.

 

검증 필터 테스트

이제 로그인한 사용자만 요청할 수 있는

API를 새로 만든 후에

액세스 토큰을 제대로 검증하는지

요청을 보내보겠다.

 

@GetMapping("/test")
public String test() {
    return "테스트 성공";
}

 

우선 위와 같이 간단한 API를 만들어준다.

 

static Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> getAuthorizeHttpRequestsConfigurer() {

    return auth -> auth
        .requestMatchers(
            new AntPathRequestMatcher("/auth/test")
        ).hasRole("USER")
        .anyRequest().permitAll();
}

 

해당 API를 로그인한 사용자만

요청을 보낼 수 있게 설정해 준다.

 

 

우선 헤더에 액세스 토큰을 포함하지 않으니

당연히 권한이 없다고 요청에 실패한다.

 

 

이제 위와 같이 액세스 토큰을 포함하여

요청을 보내면 성공하는 걸 볼 수 있다.

 

마치며

 

기존 프로젝트에서는

스프링 부트 2.7.1 / 스프링 시큐리티 5.7.1

버전을 사용했다.

 

이번에는 각각 3.1.3 / 6.1.3 버전을 사용했는데

설정 부분에서 달라진 점들이 있고

Deprecate 된 메서드들을 사용하지 않고 하려니

예상치 못한 오류들이 발생하여 애를 먹었다.

 

Deprecate 된 and 메서드를 사용을 자제하고

apply 메서드를 사용해서

커스텀한 설정을 적용하려니

필터 체인 빌드를 할 수가 없어

apply를 따로 빼서 적용시켰다.

 

글을 정리하는 요령이 없어서

다른 블로그들의 정리글에 비해

양이 많은거 같긴하다...

 

어쨌든 JWT 구현 정리를 모두 마쳤으니

OAuth 인증 방식에 대해서도 준비해 보겠다.

728x90
 

GitHub - da9dac/blank

Contribute to da9dac/blank development by creating an account on GitHub.

github.com

전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.

 

 

 

이번에 프로젝트를 마무리하면서 사용했던 기술들을 정리해 보는

시간이 필요할 것 같아 스프링 시큐리티부터 정리해 보겠다.

 

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
testImplementation 'org.springframework.security:spring-security-test'

 

우선 gradle 파일에 시큐리티와 JWT 관련 의존성을 추가해 준다.

(OAuth는 어차피 나중에 사용할 것이기 때문에 미리 넣어두었다)

 

엔티티 매핑

데이터베이스 설정은 H2나 MySQL 혹은 다른 사용 중이던

데이터베이스에 맞게 각자 설정해 준다.

@Builder
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Member extends BaseEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long memberId;

	private String nickname;

	private String email;

	private String password;

	private Authority authority;
}

 

회원 엔티티가 있어야

회원가입을 하고 로그인 정보를 검증할 테니

회원 엔티티부터 만들어준다.

 

@AllArgsConstructor
public enum Authority implements BaseEnum {
	ROLE_ADMIN("admin"),
	ROLE_USER("user");

	private final String description;

	@Override
	public String getName() {
		return name();
	}

	@Override
	public String getDescription() {
		return this.description;
	}

}

 

권한을 지정할 때 사용할 enum 클래스도 하나 추가해 준다.

이때 필드값에 어떤 것이 들어가던 상관없지만

ROLE_권한명 양식은 지켜서 만들어야 한다.

 

스프링 시큐리티는 ROLE_ 접두사가 붙은 권한을

기본적으로 인식하기 때문에 양식을 지켜야 한다.

(일관성만 유지한다면 붙이지 않고도 사용은 가능하다)

 

로그인 정보 조회 클래스

데이터베이스에서 로그인 정보를 조회해

요청값과 일치하는지 비교할 때 사용할

UserDetails와 UserDetailsService를 구현할 것이다.

 

@Getter
public class CustomUserDetails implements UserDetails {
	private Member member;

	public CustomUserDetails(Member member) {
		this.member = member;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return Collections.singleton(
			new SimpleGrantedAuthority(member.getAuthority().toString())
		);
	}

	@Override
	public String getUsername() {
		return member.getEmail();
	}

	@Override
	public String getPassword() {
		return member.getPassword();
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	} // 계정 만료 여부 (계정의 유효기간이 지난 경우)

	@Override
	public boolean isAccountNonLocked() {
		return true;
	} // 계정 잠금 여부 (여러 번의 로그인 실패 시)

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	} // 계정의 비밀번호가 만료되었는지 (비밀번호 변경 주기가 지난 경우)

	@Override
	public boolean isEnabled() {
		return  true;
	} // 계정이 일시적으로 잠긴 상태인지 여부 (관리자에 의해 일시적으로 정지된 계정 같은 경우)
}

 

필드에 멤버 엔티티를 하나 선언한 후에

생성자로 받아와 알맞게 메서드들을 구현해주면 된다.

 

boolean 값들의 경우에는 일단 기본적으로 true로 설정해주었는데

만약 엔티티에 위의 값들을 저장할 필드를 추가한다면

member.get필드명으로 값을 받아오면 된다.

 

@Service
public class CustomUserDetailsService implements UserDetailsService {

	private MemberRepository memberRepository;

	public CustomUserDetailsService(MemberRepository memberRepository) {
		this.memberRepository = memberRepository;
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

		Member member = memberRepository.findByEmail(username).orElseThrow();

		return new CustomUserDetails(member);
	}
}

 

레포지토리에서 파라미터의 username과 일치하는

회원을 찾아 위에서 만든 CustomUserDetails 객체를 생성해준다.

 

이메일과 일치하는 회원이 없는 경우에는

이메일 즉, 로그인 아이디(username)가 잘못된 경우니

아이디가 틀렸다는 등의 예외를 발생시켜도 된다.

 

이렇게 클래스들을 만들어두면

로그인 요청이 들어왔을 때 AuthenticationManager가

위의 클래스들을 사용해 로그인 정보와 실제 사용자 정보가

일치하는지 비교하여 로그인 성공 여부를 판단한다.

 

스프링 시큐리티 설정

스프링 시큐리티의 기본 설정값은

세션을 사용한 로그인 방식을 사용한다.

 

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {

		httpSecurity.apply(new CustomFilterConfigurer()); // 필터 설정

		return httpSecurity
			.httpBasic(HttpBasicConfigurer::disable) // 1
			.headers(getHeadersConfigurer()) // 2
			.csrf(CsrfConfigurer::disable) // 3
			.cors(getCorsConfigurer()) // 4
			.sessionManagement(getSessionManagementConfigurer()) // 5
			.exceptionHandling(getExceptionHandlingConfigurer()) // 6
			.authorizeHttpRequests(getAuthorizeHttpRequestsConfigurer()) // 7
			.build();
	}
}

 

우선 SecurityConfig 클래스를 생성한 후에

위와 같이 작성합니다.

 

설정에 사용된 메서드들은 잠시 뒤에 알아보고

어떤 설정들을 적용했는지 알아보겠습니다.

 

1. httpBasic 옵션은 기본적인 인증 방식으로

보안상 좋지 않기 때문에 비활성화합니다.

 

2. H2 DB를 사용하기 위해 해둔 설정이라

필수는 아니기에 생략 가능합니다.

 

3. csrf 옵션은 원래는 기본적으로 CSRF 토큰을 받은

요청만 수행하게 되어있지만 로컬에서 테스트하기 편하게

비활성화해두었습니다.

 

4. cors 옵션은 로컬에서만 테스트할 거라면

굳이 설정할 필요는 없지만 일단은

모든 URL에 대해 요청을 열어놨습니다.

 

5. 토큰 로그인 방식을 사용할 것이기 때문에

세션의 상태를 유지하지 않게 설정했습니다.

 

6. 인증 예외에 대한 처리를 설정합니다.

 

7. API 요청에 필요한 인증 및 인가의 수준을 설정합니다.

 

public class Customizers {
	// 설정을 반환할 메서드들
}

 

필터체인에 람다식으로 설정을 적용하거나

SecurityConfig 클래스 내에 메서드를 만들어도 상관없지만

가독성과 클래스의 역할을 구분하기 위해

따로 메서드와 클래스로 분리하였다

 

접근 제어자의 경우에는 Config 클래스와 같은 패키지라 default로 했지만

만약 경로가 다르다면 적절하게 바꿔줘야 한다.

 

static Customizer<CorsConfigurer<HttpSecurity>> getCorsConfigurer() {

		return cors -> {
			CorsConfiguration configuration = new CorsConfiguration();
			configuration.setAllowedOrigins(List.of("http://localhost:3000"));
			configuration.addAllowedMethod("*");
			configuration.addAllowedHeader("*");
			configuration.setAllowCredentials(true);
			configuration.setExposedHeaders(List.of(AUTHORIZATION, REFRESH, LOCATION));

			UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
			source.registerCorsConfiguration("/**", configuration);

			cors.configurationSource(source);
		};
}

 

CORS 설정으로 모든 HTTP 메서드와 헤더에 대해 요청을 허용하고

일단 허용 주소는 프런트 측이 로컬에서 자주 사용하는 3000번 포트로 열어놨다.

(그냥 "*"을 적어놔도 상관없다)

 

자격 증명 요청 여부도 true로 설정하고

헤더에 액세스 토큰, 리프래시 토큰, 리디렉트 등을 노출하도록 했다.

 

마지막으로 이 설정들을 어떤 url들에 적용할지 정하는데

모든 url에 적용되게 해 놨다.

 

나중에 프런트와 백엔드가 통합된 환경에서만 필요한 설정이라

로컬에서 혼자 연습만 할 거라면

굳이 cors 설정은 해줄 필요 없다.

 

static Customizer<SessionManagementConfigurer<HttpSecurity>> getSessionManagementConfigurer() {

		return sessionManagementConfigurer -> new SessionManagementConfigurer<>()
			.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

 

세션 로그인을 사용하지 않을 것이기 때문에

세션 정책을 상태를 유지하지 않게 바꿔준다.

 

static Customizer<ExceptionHandlingConfigurer<HttpSecurity>> getExceptionHandlingConfigurer() {

		return exceptionHandlingConfigurer -> new ExceptionHandlingConfigurer<>()
			.authenticationEntryPoint(getCustomAuthenticationEntryPoint())
			.accessDeniedHandler(getCustomAccessDeniedHandler());
}

 

인증 및 인가 실패 시 어떻게 할지 설정해 주는 부분으로

인증에 실패하거나 인가에 실패할 시

어떤 동작들을 실행할지 지정해 준다.

 

static AuthenticationEntryPoint getCustomAuthenticationEntryPoint() {
		return new CustomAuthenticationEntryPoint();
}

static AccessDeniedHandler getCustomAccessDeniedHandler() {
		return new CustomAccessDeniedHandler();
}

 

사용된 메서드들은 그냥 직접 만든 클래스들을 리턴하는 메서드다.

 

// AuthenticationEntryPoint
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {

	}
}

// AccessDeniedHandler
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response,
		AccessDeniedException accessDeniedException) throws IOException, ServletException {

	}
}

 

각각의 클래스들은 위와 같이 생겼고

오버라이딩한 메서드 안에 자신이 실행시키고 싶은

로직을 추가하면 된다.

(필수는 아니지만 해두면 좋다)

 

static Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> getAuthorizeHttpRequestsConfigurer() {

    return auth -> auth
        .requestMatchers(
            new AntPathRequestMatcher("/"),
            new AntPathRequestMatcher("/h2-console/**"),
            new AntPathRequestMatcher("/profile")
        ).permitAll()
        .anyRequest().permitAll();
}

 

어떤 요청들에 어떤 권한이 필요하고

권한이 필요 없는지 등을

지정해 줄 수 있는 설정이다.

 

자신이 인증 및 인가가 필요한 API 설정에 맞게

커스텀하여 사용하면 되고

스프링 부트 2.7.1, 시큐리티 5 버전까지는 문제가 없었지만

위와 같이 new AntPathRequestMatcher() 형식을

명시적으로 지정해주지 않으면 h2 콘솔 사용 시

예외가 발생한다.

 

지금처럼 API가 별로 없는 경우에는

따로 분리할 필요 없이

필터체인 메서드 안에서 람다식으로 해도 괜찮지만

API가 많아져 설정이 복잡해진다면

위와 같이 메서드로 분리하는 것이 깔끔하다.

 

커스텀 필터 설정

이제 마지막 설정으로는

로그인 정보를 확인하고 액세스 토큰을 발급하는 필터와

액세스 토큰 만료 시 리프래시 토큰으로 재발급하는 필터,

액세스 토큰이 유효한지 확인하는 필터를 추가하는 작업을 할 것이다.

 

private class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
    @Override
    public void configure(HttpSecurity builder) throws Exception {
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtProvider, authenticationManager);
        jwtAuthenticationFilter.setFilterProcessesUrl(LOGIN_URL);
        // jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
        // jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

        JwtRefreshFilter jwtRefreshFilter = new JwtRefreshFilter(jwtProvider);
        JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtProvider);

        builder
            .addFilter(jwtAuthenticationFilter)
            .addFilterAfter(jwtRefreshFilter, JwtAuthenticationFilter.class)
            .addFilterAfter(jwtVerificationFilter, JwtRefreshFilter.class);
    }
}

 

아직 필터 클래스를 구현하진 않았지만

위와 같은 필터들을 구현할 것이기에

컴파일 에러가 뜨더라도 적어만 둔다.

 

성공 및 실패 핸들러는 빼도 상관 없다.

 

필터의 순서는 필터 체인 설정에 보이는 것처럼

인증 필터, 리프래시 필터, 검증 필터 순으로 넣어준다.

 

토큰 생성 및 검증 클래스

이제 구현해야 하는 클래스의 기능은 크게 세 가지다.

 

1. 사용자의 로그인 정보를 토대로

액세스 토큰과 리프래시 토큰을 발급하기

 

2. 리프래시 토큰으로 액세스 토큰을 재발급하기

 

3. 토큰 검증하기

 

@Component
public class JwtProvider {
	
}

 

일단 위와 같은 클래스를 새로 만든 후에

위에서 정리한 기능들을 구현한다.

 

@Value("${jwt.key}")
private String key;

private Key secretKey;

@PostConstruct
protected void init() {
    secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
}

 

key는 JWT 토큰 서명에 사용될 암호화 하기 전의 키로

yml이나 properties 파일에 값을 저장한다.

(학습용이라면 변수에 직접 명시해 줘도 상관없다)

 

다음 필드인 secretKey는 암호화된 키가 저장될 변수로

init 메서드를 사용해 초기화를 해준다.

 

시크릿키는 실제 토큰 생성에 사용되는 값으로

BASE64 형식으로 디코딩된 키에

HMAC SHA 알고리즘을 적용한 값을 할당한다.

 

즉, key를 알아보기 어렵게 암호화한

secretKey로 토큰을 생성한다.

 

public String createAccessToken(Authentication authentication, Long tokenExpireTime) {

    return Jwts.builder()
        .setSubject(authentication.getName())
        .claim(CLAIM_ID, getId(authentication))
        .claim(CLAIM_AUTHORITY, getAuthorities(authentication))
        .setExpiration(getExpiration(tokenExpireTime))
        .signWith(secretKey, SignatureAlgorithm.HS512)
        .compact();
}

 

다음은 액세스 토큰을 생성하는 메서드를 구현한다.

 

빌더 패턴을 사용하여

JWT 토큰의 구성을 설정해주는데

Authentication 객체의 getName 메서드는

사용자의 이메일을 가져오고

객체 안의 Principal에는 UserDetails가 들어있다.

 

claim에는 자신이 토큰에 포함하고 싶은

사용자의 정보를 추가해주면 되고

토큰의 만료시간과

토큰의 서명에 사용할 키를 지정해주면 된다.

 

보통 프런트가 필요한 데이터들을 claim에 넣어준다.

 

private Long getId(Authentication authentication) {

    if(authentication.getPrincipal() instanceof CustomUserDetails customUserDetails){
        return customUserDetails.getMember().getMemberId();
    }

    return null;
}

 

다음은 Claim에 넣어줄 회원의 ID를 가져오는 메서드로

여기서 ID는 이메일이 아니라 회원 테이블의 기본키다.

 

private String getAuthorities(Authentication authentication) {

    return authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(","));
}

 

비슷하게 해당 회원이 가진 권한도 Claim에 넣어준다.

 

private Date getExpiration(Long tokenExpireTime) {
    return new Date(new Date().getTime() + tokenExpireTime);
}

 

토큰의 만료 시간도 현재 시간을 기준으로 설정해준다.

 

public String createRefreshToken(Authentication authentication, long tokenExpireTime){

    return Jwts.builder()
        .setSubject(authentication.getName())
        .claim(CLAIM_ID, getId(authentication))
        .setExpiration(getExpiration(tokenExpireTime))
        .signWith(secretKey, SignatureAlgorithm.HS512)
        .compact();
}

 

다음은 리프래시 토큰을 생성하는 메서드로

액세스 토큰과 비슷하지만

액세스 토큰 재발급에만 쓰일 토큰이기 때문에

굳이 권한과 같은 정보를 가지고 있을 필요가 없다.

 

그래서 만료시간 정도만 액세스 토큰보다

길게 설정하여 만들어주면 된다.

 

public String refillAccessToken(String refreshToken, long tokenExpireTime) {
    Claims claims = getClaims(refreshToken);
    String username = claims.getSubject();

    UserDetails userDetails = userDetailsService.loadUserByUsername(username);

    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
        userDetails, null, userDetails.getAuthorities());

    return createAccessToken(authentication, tokenExpireTime);
}

 

리프래시 토큰으로 액세스 토큰을

재발급 할 때 사용할 메서드다.

 

액세스 토큰을 만들 때 사용할

Authentication 객체를 만들기 위해

사용자의 이메일로 UserDetails를 가져온 후에

Authentication 객체를 만들고

이를 사용해 액세스 토큰을 재발급 받는다.

 

public Claims getClaims(String token) {
    try {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    } catch (ExpiredJwtException e) {
        throw new JwtExpiredException();
    } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
        throw new JwtNotValidException();
    }
}

 

위에서 사용한

토큰에서 Claims를 가져오는 메서드로

토큰을 비밀키로 복호화하는 과정에서

만료되거나 잘못된 토큰인 경우

예외가 발생한다.

 

위와 같이 간단하게 예외 클래스들을 만들어서

처리해줘도 괜찮고

귀찮다면 대충 런타임 예외를 던져버려도 상관없다.

(하지만 되도록이면 예외는 처리해주도록 하자)

 

public void validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    } catch (ExpiredJwtException e) {
        throw new JwtExpiredException();
    } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
        throw new JwtNotValidException();
    }
}

 

나중에 만들 토큰 검증 필터에서 사용할

토큰이 정상적인지 검증할 메서드로

Claims를 가져오는 메서드와

반환값의 차이만 있다.

 

시큐리티 설정과 토큰 생성 및 검증에 대한

구현을 모두 끝냈고
다음 게시글에서 필터를 구현해보겠다.

728x90

Storage Mechanisms

Username과 Password를 읽기 위해 제공되는 저장 기술들(UserDetailsService의 구현체)은 아래와 같다.

  • In-Memory Authentication
  • JDBC Authentication (JDBC를 사용하는 경우)
  • UserDetailsService (커스텀 데이터 베이스를 사용하는 경우)
  • LDAP Authentication (LDAP 저장소를 사용하는 경우)

 

UserDetailsService

저장 기술들의 종류로 해당 인터페이스를 구현한 클래스들을 사용하여

UserDetails의 관리를 제공하고 인증을 할 수 있게 한다.

(기본적으로 인메모리 방식과 JDBC 방식을 지원한다.)

 

이를 사용한 인증 방식은 Username과 Password를 사용하는 인증 방식 사용 시

스프링 시큐리티에서 기본적으로 사용하게 설정된다.

@Bean
CustomUserDetailsService customUserDetailsService() {
	return new CustomUserDetailsService();
}

위와 같이 UserDetailsService를 직접 커스텀하여 빈으로 등록하여 사용할 수도 있다.

UserDetails

UserDetailsManager를 통해 얻을 수 있는 객체로

DaoAuthenticationProvider가 UserDetails가 유효한지 확인한 후에

UserDetailsManager로 부터 인증된 UserDetails를 반환한다.

 

즉, user라는 Username을 가진 UserDetails가 있는지 확인한 후에

password까지 일치한다면 인증이 추가된 UserDetails를 반환한다.

 

PasswordEncoder

인코더라는 단어에서 알 수 있듯이 패스워드를 암호화하여 저장할 수 있는 기능을 지원한다.

PasswordEncoder도 마찬가지로 커스텀 구현하여 빈으로 등록하여 사용할 수 있다.

 

DaoAuthenticationProvider

위에서 알아본대로 UserDetailsService와 PasswordEncoder를 사용하여

Username 및 Password 기반의 인증을 수행하는 AuthenticationProvider의 구현 클래스다.

 

 

인증 과정을 순서대로 살펴보자면 아래와 같다.

  1. 아직 인증되지 않은 UsernamePasswordAuthenticationToken(토큰이라고 부르겠음)을 DaoAuthenticationProvider로 전달한다.
  2. 해당 토큰의 정보로 UserDetailsService에서 UserDetails를 조회한다.
  3. 그 후 PasswordEncoder를 사용하여 UserDetails의 패스워드와 토큰의 패스워드를 검증한다.
  4. 인증에 성공하면 인증이 추가된 토큰을 반환한다.
  5. 이는 최종적으로 필터에 의해 SecurityContextHolder에 저장된다.

In-Memory Authentication

InMemoryUserDetailsManager를 사용하는 인증 방식이다.

@Bean
public UserDetailsService users() {
	//User의 UserDetails 생성
	UserDetails user = User.builder()
		.username("user")
		.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
		.roles("USER")
		.build();
	//Admin의 UserDetails 생성
	UserDetails admin = User.builder()
		.username("admin")
		.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
		.roles("USER", "ADMIN")
		.build();
	return new InMemoryUserDetailsManager(user, admin);
}

위의 코드처럼 username, password, role을 지정하여 UserDetails를 생성한 후에

이를 사용해 InMemoryUserDetailsManager 객체를 생성한다.

 

어디서 많이 본거 같은 익숙한 느낌이 들 수도 있는데

이는 스프링 시큐리티의 기본 로그인 페이지에 접속할 때 사용하던

접속 정보를 생성해주던 코드이다.

 

즉, 서버 실행 시 콘솔창에 출력된 랜덤 비밀번호와 user라고 입력해서 로그인을 하면

해당 정보를 가지고 위의 InMemoryUserDetailsManager를 사용해

해당 정보와 일치하는 UserDetails가 있는지 확인하여 로그인이 되던 것이다.

 

하지만 인메모리 저장소의 휘발성이라는 특성 때문에

개발 및 테스트 단계에서만 사용하는 것을 권장하고 실제 배포 단계에서는 사용하면 안된다.

728x90

Username/Password

가장 일반적으로 사용되는 인증 방식으로

아이디와 패스워드를 사용해 로그인하는 방식이라고 볼 수 있다.

 

스프링 시큐리티는 HttpServletRequest에서

Username과 Password를 읽기 위해 아래와 같은 방식을 지원한다.

  • Form
  • Basic
  • Digest

 

Form

우선 로그인하지 않은 사용자가 인증이 필요한 리소스에 요청한 경우

로그인 페이지로 리다이렉트 되는 과정을 먼저 살펴보겠다.

 

 

 

우선 인증되지 않은 사용자가 인증이 필요한 리소스에 요청을 보내면

AuthorizationFilter가 AccessDeniedException 예외를 던진다.

 

인증되지 않은 사용자이기 때문에 ExceptionTranslationFilter가 인증을 시작하고

AuthenticationEntryPoint를 이용하여 로그인 페이지로 리다이렉트 시킨다.

 

리다이렉트 된 사용자는 로그인 페이지를 요청하고

로그인 컨트롤러가 로그인 페이지를 응답한다.

 

 

사용자가 로그인 페이지에서 Username과 Password를 보내면

UsernamePasswordAuthenticationFilter가 이를 통해 인증을 시작한다.

 

이후의 과정은 이전에 살펴본 내용과 유사하다.

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		.formLogin(withDefaults());
	// ...
}

스프링 시큐리티는 기본적으로 이러한 Form 로그인 방식이 활성화되어 있지만

위의 코드와 같이 서블릿 기반 구성이 제공된다면

Form 로그인 방식을 명시적으로 반드시 지정해야 한다.

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		.formLogin(form -> form
			.loginPage("/login") //로그인 페이지 지정
			.permitAll() //로그인 페이지의 접근권한 지정
		);
}

위와 같이 직접 커스텀한 로그인 페이지를 지정해 줄 수도 있다.

 

로그인 페이지에서 Username과 Password를 보낼 때는 Post 메서드를 이용해서 보내야 한다.

(이때 요청을 보낼 때 파라미터의 이름을 username과 password로 보내야 한다.)

@Controller
class LoginController {
	@GetMapping("/login")
	String login() {
		return "login";
	}
}

커스텀 로그인 페이지를 설정했다면 스프링 MVC 컨트롤러에서는

Get 컨트롤러에 자신이 지정한 페이지(/login)를 매핑해줘야 한다.

 

Basic

 

Form 방식에서는 인증되지 않은 사용자의 요청에 대해 로그인 페이지로 리다이렉트 했다면

Basic 방식에서는 인증되지 않은 사용자에게 로그인을 다시 요청한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		// ...
		.httpBasic(withDefaults());
	return http.build();
}

이 방식도 마찬가지로 스프링 시큐리티에서 기본적으로 활성화되어 있지만

서블릿 기반 설정을 적용한다면 위와 같이 명시적으로 적어줘야 한다.

 

Digest

크레덴셜이 일반 텍스트 형태로 전송되지 않게 Basic 방식의 문제점을 해결하려는 방법이지만

암호의 저장 방식이 보안상 좋지 않기 때문에 사용하는 것을 권장하지 않는다.

728x90

SecurityContextHolder

 

 

스프링 시큐리티 인증 모델의 핵심으로 SecurityContext를 포함한다.

 

Holder라는 단어의 의미를 생각해 보면 이해하기 쉬운데

여기서 Holder는 소유자, 보유자 같은 의미로

SecurityContext를 가지고 있는 존재라는 것이다.

 

위의 사진을 보면 SecurityContext는 인증과 관련된 정보들을 가지고 있는데

즉, SecurityContextHolder는 이러한 SecurityContext들을 저장하는 공간이다.

//SecurityContextHolder에 저장할 SecurityContext를 하나 생성
SecurityContext context = SecurityContextHolder.createEmptyContext(); 

//SecurityContext에 저장할 인증정보 생성
Authentication authentication =
    new TestingAuthenticationToken("username", "password", "ROLE_USER"); 

//SecurityContext에 생성한 인증정보 할당
context.setAuthentication(authentication);

//SecurityContext를 SecurityContextHolder에 저장
SecurityContextHolder.setContext(context);

SecurityContextHolder에 SecurityContext를 저장하는 간단한 코드다.

 

결국 인증에 대한 모든 정보들은 SecurityContextHolder가 가지고 있기 때문에

이를 얻기 위해서는 SecurityContextHolder에 접근해야 한다.

//SecurityContextHolder에서 SecurityContext 가져오기
SecurityContext context = SecurityContextHolder.getContext();

//SecurityContext에서 인증정보 가져오기
Authentication authentication = context.getAuthentication();

//인증정보에서 필요한 정보들 가져오기
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

SecurityContextHolder >> SecurityContext >> Authentication

위와 같은 순서로 인증정보에 접근할 수 있다.

 

SecurityContextHolder는 ThreadLocal을 사용해 인증에 대한 상세정보들을 저장하는데

이해하기 쉽게 설명하자면 동일한 스레드의 메서드에서는 SecurityContext룰 언제든 사용할 수 있다.

public void testMethod(파라미터로 SecurityContextHolder를 받지 않아도) {
	//아래의 과정들을 그대로 수행할 수 있음
	//SecurityContextHolder에서 SecurityContext 가져오기
	//SecurityContext에서 인증정보 가져오기
	//인증정보에서 필요한 정보들 가져오기
}

SecurityContext를 메서드의 파라미터 같은 것으로 전달받지 않아도

현재 스레드의 어떤 메서드에서든 SecurityContextHolder를 통해

SecurityContext에 접근할 수 있다는 것이다.

 

SecurityContext

위에서 살펴봤던 것처럼 인증정보를 감싸고 있는 객체로

SecurityContextHolder에 포함된다.

Authentication

Authentication은 스프링 시큐리티에서 두 가지 목적을 가지고 사용할 수 있다.

 

첫 번째로는 인증을 위한 크레덴셜을 제공하기 위한 AuthenticationManager에 대한 입력으로

이 경우에는 isAuthenticated()의 리턴값은 false다.

 

두 번째로는 현재 인증된 사용자를 나타내기 위한 경우로

위에서 살펴본 SecurityContext로부터 인증정보를 얻을 때 사용된다.

 

정리하자면 인증을 위한 크레덴셜 제공과

이미 인증된 사용자의 정보를 얻기 위한 목적을 가지고 있다.

 

다음의 3가지를 포함한다.

  • Principal : 보안주체로 사용자를 식별할 때 사용된다.
  • Credentials : 인증에 필요한 수단으로 보통 패스워드를 의미한다.
  • Authorities : GrantedAuthority 객체로 사용자가 어디까지 접근할 수 있는지에 대한 권한이다.(역할 및 범위)

GrantedAuthority

인증된 사용자가 어떤 요청까지 가능한지에 대한 권한을 부여하는 객체로

역할 기반과 범위 기반 방식이 있다.

 

예를 들면, 로그인한 사용자의 역할이 일반 사용자인지 관리자인지에 따라

어떤 페이지에(요청) 접근할 수 있는지 통제할 수 있다.

AuthenticationManager

스프링 시큐리티 필터가 인증을 어떻게 수행하는지 정의하는 API로

실질적인 인증을 직접 처리하는 것이 아니라

인증 과정들을 순서대로 진행하도록 조율한다.

 

AuthenticationManager를 통해 최종적으로 반환된 Authentication은

AuthenticationManager를 호출한 컨트롤러(사실상 스프링 시큐리티의 필터)에 의해 

SecurityContext에 감싸져 SecurityContextHolder에 저장된다.

 

SecurityContextHolder에 대해서 살펴볼 때 봤던 코드처럼

직접 지정하거나 AuthenticationManager를 사용하지 않는 방법도 있다.

 

일반적으로 AuthenticationManager의 구현 클래스는 ProviderManager다.

ProviderManager

 

 

ProviderManager는 인증의 수행을 AuthenticationProvider의 인스턴스 목록에 위임하는데

각각의 AuthenticationProvider는 인증의 성공, 실패에 대해 결정하거나

결정할 수 없는 경우 다음 AuthenticationProvider가 결정할 수 있도록 한다.

 

즉, 각각의 AuthenticationProvider는 특정 인증 방식에 대한 인증을 수행하는데

예를 들어 첫번째 AuthenticationProvider는 Username/Password 방식을 수행하면

다른 하나는 SAML 방식의 인증을 수행한다.

 

간단하게 설명하자면 하나의 ProviderManager를 통해

여러 인증 방식(AuthenticationProvider)을 사용할 수 있다.

AuthenticationProvider

여러 인증 방식(AuthenticationProvider)을 ProviderManager에 주입할 수 있다.

 

위에서도 언급했듯이 각각의 AuthenticationProvider는 특정 인증을 수행하는데

예를 들면 DaoAuthenticationProvider를 주입하면 유저네임/패스워드 기반의 인증을,

JwtAuthenticationProvider를 주입하면 JWT 토큰 방식의 인증도 수행할 수 있다.

Request Credentials with AuthenticationEntryPoint

클라이언트에게 자격증명을 요청하는 HTTP 응답을 처리할 때 사용된다.

 

쉽게 말하자면 인증되지 않은 요청에 대해 클라이언트에게 다시 인증을 요구하는 것으로

로그인하지 않은 사용자가 마이 페이지에 접속하는 경우에

로그인 페이지로 리다이렉트 하는 경우에 사용된다고 이해하면 된다.

AbstractAuthenticationProcessingFilter

사용자의 크레덴셜을 인증하기 위한 기본 필터로

크레덴셜이 인증되기 전에 스프링 시큐리티는 일반적으로

AuthenticationEntryPoint를 사용해 크레덴셜을 요청한다.

 

그 후에 AbstractAuthenticationProcessingFilter가

제출된 모든 인증 요청에 대해 인증할 수 있다.

 

 

 

간단하게 순서를 살펴보자면 아래와 같다.

  1. 사용자가 자신의 크레덴셜을 제공
  2. AbstractAuthenticationProcessingFilter가 인증할 Authentication을 생성 (아직 인증되지 않은 상태)
  3. 생성된 Authentication이 AuthenticationManager에 전달
  4. 인증에 실패하면 SecurityContextHolder를 비우고 실패에 대한 작업을 진행
  5. 인증에 성공하면 SecurityContextHolder에 Authentication을 저장하는 등 성공에 대한 작업 진행
728x90

의존성 추가

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
}

gradle 파일에 위의 스프링 시큐리티 의존성을 추가한 후에

별다른 설정 없이 서버를 실행한 후에 로컬 환경에서 사이트에 접속해보겠다.

 

 

접속해보면 로그인 페이지나 기능을 구현한 적이 없어도

스프링 시큐리티에서 기본적으로 제공해주는 인증 페이지로 접속되는걸 확인할 수 있다.

 

 

 

기본 인증은 Username에 user, Password에 위의 비밀번호를 복사해서 로그인 할 수 있다.

 

인증을 하기 전까지는 어떤 요청을 보내더라도 위의 페이지가 뜨게 되고

인증된 후에야 응답을 받을 수 있게 된다.

 

 

시큐리티의 기본적인 인증 방식은 위의 사진에서 알 수 있듯이

세션을 사용하여 인증 정보를 관리한다.

@EnableWebSecurity 
@Configuration
public class DefaultSecurityConfig {
    @Bean
    @ConditionalOnMissingBean(UserDetailsService.class)
    InMemoryUserDetailsManager inMemoryUserDetailsManager() { 
        String generatedPassword = // 랜덤 패스워드 생성;
        return new InMemoryUserDetailsManager(User.withUsername("user")
                .password(generatedPassword).roles("ROLE_USER").build());
    }

    @Bean
    @ConditionalOnMissingBean(AuthenticationEventPublisher.class)
    DefaultAuthenticationEventPublisher defaultAuthenticationEventPublisher(ApplicationEventPublisher delegate) { 
        return new DefaultAuthenticationEventPublisher(delegate);
    }
}

위의 코드는 스프링 시큐리티의 기본 설정값이다.

 

서버를 처음 실행할 때마다 랜덤으로 패스워드를 설정하여

Username은 user고 Password는 랜덤 패스워드, 역할은 ROLE_USER인 인증 정보를 생성한다.

 

이런 과정을 통해서 의존성을 추가한 것만으로도 기본 로그인 기능을 사용할 수 있었다.

 

728x90

OAuth 2.0 인증 컴포넌트

 

Resource Owner

사용하고자 하는 리소스의 주인으로 웹 애플리케이션의 실제 사용자라고 생각하면 편하다.

 

구글 로그인 인증을 통해 사용자가 자신의 구글 계정으로

자신의 리소스를 사용하는 것이기 때문에 Resource Owner라고 부른다.

 

Client

써드 파티 애플리케이션을 사용하지 않는 기존의 웹 애플리케이션에서

클라이언트는 사용자에 해당했지만

OAuth 2.0에서의 클라이언트는 웹 애플리케이션에 해당한다.

 

클라이언트는 서비스를 이용하는 쪽이고 서버는 제공하는 쪽인데

써드 파티 애플리케이션의 서비스를 제공받는 쪽은

웹 애플리케이션에 해당하기 때문이다.

 

Resource Server

클라이언트의 요청을 수락하고 Resource Owner의 Resource를 제공하는 서버로

써드 파티 애플리케이션의 서비스를 제공하는 쪽이 여기에 해당한다고 생각하면 된다.

 

Authorization Server

클라이언트가 Resource Server에 접근할 수 있는 권한을 부여하는 서버로

인증에 성공한 클라이언트에 Resource 접근 권한이 있는 액세스 토큰을 부여한다.

 

인증 처리 흐름

  1. Resource Owner의 인증 요청
  2. 웹 애플리케이션 서버가 써드 파티 로그인 페이지로 Redirect
  3. Authorization Server가 Resource 접근 권한이 있는 액세스 토큰 생성 후 전달
  4. 웹 애플리케이션 서버가 액세스 토큰으로 Resource Server에 요청
  5. Resource Server가 Resource Owner의 Resource 전달

 

용어

Authorization Grant

클라이언트가 액세스 토큰을 얻기 위한 수단을

Resource Owner의 권한을 표현하는 크리덴셜이다.

 

아래와 같이 4가지 타입이 있다.

  1. Authorization Code
    • 자체 생성한 Authorization Code를 전달하여 권한 부여 승인을 하는 방식
    • 가장 많이 쓰이는 방식
    • Refresh Token 사용 가능
    • 권한 부여 승인 요청 시 응답 타입을 code로 지정하여 요청
  2. Implicit Grant Type
    • Authorization Code 없이 바로 액세스 토큰을 발급하는 방식
    • 자격증명을 안전하게 저장하기 힘든 클라이언트에서 사용
    • Refresh Token 사용 불가능
    • 권한 부여 승인 요청 시 응답 타입을 token으로 지정하여 요청
  3. Client Credentials
    • 클라이언트가 관리하는 리소스에 접근할 때 사용
    • Authorization Server에 클라이언트에 제한된 리소스 접근 권한이 설정된 경우 사용
    • 자격 증명을 안전하게 보관할 수 있는 클라이언트에서만 사용해야 함
    • Refresh Token 사용 불가능
  4. Resource Owner Password Credentials
    • 로그인 시 필요한 정보로 액세스 토큰을 발급받는 방식
    • 같은 서비스에서 제공하는 애플리케이션의 경우에만 사용
    • 네이버 계정으로 로그인 하여 네이버의 웹툰, 카페, 블로그 등을 이용하는 경우가 해당
    • 즉 권한 부여 서버, 리소스 서버, 클라이언트가 모두 같은 시스템이여야 함
    • Refresh Token 사용 가능

 

Access Token

클라이언트가 리소스 서버의 보호된 리소스에 액세스 하기 위해 사용하는 자격증명용 토큰

 

Scope

주어진 액세스 토큰을 사용할 수 있는 범위로

액세스할 수 있는 리소스의 범위다.

+ Recent posts