로그인 프로세스

2021. 6. 27. 16:03Spring Security/Spring Boot Jwt with JPA and Redis

반응형

사용자 로그인에 대한 로직을 살펴보도록 하겠습니다.

아래 시퀀스 다이어그램을 통해서 전체적인 로직을 확인 해보도록 하겠습니다.

로그인 프로세스

JwtFilter

로그인시 발급된 토큰 및 쿠키 정보가 없기 때문에 로직 수행없이 LoginController 의 POST /signin 을 수행하게 됩니다.

 

LoginController.signin

package com.roopy.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.roopy.crypto.AES256Cipher;
import com.roopy.jwt.JwtFilter;
import com.roopy.security.jwt.payload.request.LoginRequest;
import com.roopy.security.jwt.payload.response.TokenResponse;
import com.roopy.util.CookieUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.HashMap;

@RestController
public class LoginController {

    private static final Logger logger = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private CookieUtil cookieUtil;

    @Value("${jwt.user-cookie-name}")
    private String userCookieName;

    @Value("${jwt.access-token-cookie-name}")
    private String accessTokenCookieName;

    @Value("${jwt.refresh-token-cookie-name}")
    private String refreshTokenCookieName;

    /**
     * 로그인
     *
     * <p>
     *     1.인증서버에 AccessToken, RefreshToken 발급요청
     *     2.발급된 토큰 정보에 대한 쿠키 생성
     * </p>
     *
     *
     * @param httpServletRequest HttpServletRequest
     * @param httpServletResponse HttpServletResponse
     * @param loginRequest set username and password
     * @return TokenResponse accessToken
     * @throws Exception
     */
    @PostMapping("/signin")
    public ResponseEntity<TokenResponse> signin(HttpServletRequest httpServletRequest
            , HttpServletResponse httpServletResponse
            , @Valid @RequestBody LoginRequest loginRequest) throws Exception {

        CookieUtil cookieUtil = new CookieUtil();

        // 토큰 발행 요청
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        ObjectMapper mapper = new ObjectMapper();

        HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(loginRequest), headers);
        ResponseEntity<HashMap> responseEntity = restTemplate.postForEntity( "http://localhost:10001/token/issue", request , HashMap.class );

        // 토큰 발행 요청 결과
        String accessToken = null;
        String refreshToken = null;

        Cookie userCookie = null;
        Cookie accessTokenCookie = null;
        Cookie refreshTokenCookie = null;

        if (responseEntity.getStatusCodeValue() == HttpStatus.OK.value()) {
            accessToken = (String) responseEntity.getBody().get("accessToken");
            refreshToken = (String) responseEntity.getBody().get("refreshToken");

            // User 쿠키 설정
            // Token 쿠키가 다 삭제되는 경우 h2db, Redis 서버에 존재하는 토큰 정보
            // 를 삭제 처리를 위해서
            // username 을 설정한다.
            // 단 토큰 삭제 처리 후 User 쿠키도 삭제 처리 해야 한다.
            userCookie = cookieUtil.createCookie(userCookieName, AES256Cipher.encrypt(loginRequest.getUsername()), "");

            // AccessToken 쿠키 저장
            accessTokenCookie = cookieUtil.createCookie(accessTokenCookieName, accessToken, "A");

            // RefreshToken 쿠키 저장
            refreshTokenCookie = cookieUtil.createCookie(refreshTokenCookieName, AES256Cipher.encrypt(refreshToken), "R");

            httpServletResponse.addCookie(userCookie);
            httpServletResponse.addCookie(accessTokenCookie);
            httpServletResponse.addCookie(refreshTokenCookie);

            logger.debug("=================================================================================");
            logger.debug("Log in : AccessToken({}), RefreshToken({}) 발급 완료", accessToken, refreshToken);
            logger.debug("=================================================================================");
        }

        return new ResponseEntity<>(new TokenResponse(accessToken), HttpStatus.OK);
    }

    /**
     * 로그아웃
     * 
     * @param httpServletRequest HttpServletRequest
     * @param httpServletResponse HttpServletResponse
     * @return
     * @throws Exception
     */
    @PostMapping("/signout")
    public ResponseEntity<String> signout(HttpServletRequest httpServletRequest
            , HttpServletResponse httpServletResponse) throws Exception {

        // 사용자 쿠키 삭제
        Cookie userCookie = cookieUtil.expireCookie(userCookieName);

        // AccessToken 쿠키 삭제
        Cookie accessTokenCookie = cookieUtil.expireCookie(accessTokenCookieName);

        // RefreshToken 쿠키 삭제
        Cookie refreshTokenCookie = cookieUtil.expireCookie(refreshTokenCookieName);

        httpServletResponse.addCookie(userCookie);
        httpServletResponse.addCookie(accessTokenCookie);
        httpServletResponse.addCookie(refreshTokenCookie);

        return new ResponseEntity<>("로그 아웃 처리 되었습니다.", HttpStatus.OK);
    }
}

 

소스에서 토큰 발행 요청은 로그인시 입력받은 사용자ID와 비밀번호를 통하여서 auth-server에 토큰 생성을 요청한다.

auth-server 에서는 생성된 accessToken과 refreshToken 정보를 반환하게 된다.

 

TokenController.issueToken

/**
 * 토큰 관리를 위한 클래스
 */
@RestController
@RequestMapping("/token")
public class TokenController {

    @Autowired
    private AuthService authService;

    /**
     * 토큰 발급
     *
     * @param loginRequest 토큰 발급을 위한 ID, 비밀번호
     * @return ResponseEntity<Map<String,Object>> AccessToken, RefreshToken
     */
    @PostMapping("/issue")
    public ResponseEntity<Map<String,Object>> issueToken(@Valid @RequestBody LoginRequest loginRequest) throws Exception {
        TokenResponse tokenResponse = authService.generateAccessTokenAndRefreshToken(loginRequest);

        Map<String,Object> retObj = new HashMap<>();
        retObj.put("accessToken", tokenResponse.getAccessToken());
        retObj.put("refreshToken", tokenResponse.getRefreshToken());

        return new ResponseEntity<>(retObj, HttpStatus.OK);
    }
    
}

토큰 발급을 위해서 LoginController 에서 전달받은 파라미터(사용자ID,비밀번호)로 AccessToken과 RefreshToken을 생성을 위해 AuthService.generateAccessTokenAndRefresh 메소드를 호출 한다.

 

AuthService.generateAccessTokenAndRefreshToken

package com.roopy.service.impl;

import com.roopy.crypto.AES256Cipher;
import com.roopy.entity.RefreshToken;
import com.roopy.entity.User;
import com.roopy.exception.TokenNotFoundException;
import com.roopy.repository.RefreshTokenRepository;
import com.roopy.repository.UserRepository;
import com.roopy.security.jwt.TokenProvider;
import com.roopy.security.jwt.payload.request.LoginRequest;
import com.roopy.security.jwt.payload.response.TokenResponse;
import com.roopy.service.AuthService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Optional;
import java.util.UUID;

@Service
public class AuthServiceImpl implements AuthService {

    private final Logger logger = LoggerFactory.getLogger(AuthService.class);

    @Autowired
    private AuthenticationManagerBuilder authenticationManagerBuilder;

    @Autowired
    private TokenProvider tokenProvider;

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private CustomUserDetailsServiceImpl userDetailsService;

    @Override
    @Transactional
    public TokenResponse generateAccessTokenAndRefreshToken(LoginRequest loginRequest) throws Exception {
        // 1. accessToken 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());

        Authentication authentication = null;
        try {
            authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        } catch (BadCredentialsException e) {
            throw new BadCredentialsException("[" + loginRequest.getUsername() + "] 사용자 정보가 존재 하지 않습니다.");
        }

        String accessToken = tokenProvider.createToken(authentication);
        logger.debug("AccessToken({}) 이 정상적으로 발급 되었습니다.", accessToken);

        // 2. refreshToken 생성
        Calendar c = Calendar.getInstance();
        
        // 만료시간 5분 설정
        c.add(Calendar.MINUTE, 5);

        RefreshToken refreshTokenRequest = RefreshToken.builder()
                                        .id(UUID.randomUUID().toString())
                                        .username(loginRequest.getUsername())
                                        .password(AES256Cipher.encrypt(loginRequest.getPassword()))
                                        .expiryDate(c.getTime())
                                        .build();

        RefreshToken refreshTokenResponse = refreshTokenRepository.save(refreshTokenRequest);
        logger.debug("RefreshToken({}) 이 정상적으로 발급 되었습니다.", refreshTokenResponse.getId());

        // RefreshToken 재발급후 AccessToken 재발급시 Encoding 되지 않은 사용자의 비밀번호가 필요 한데
        // Encoding 전의 비밀번호는 Redis 서버에에 저장 하고 있는데 RefreshToken 만료시 쿠키 정보가 삭제
        // 되므로 사용자의 비밀번호를 알 수 없으므로 사용자 테이블에 RefreshToken 발급전 토큰 정보를 업데이트 처리한다.
        Optional<User> user =  userRepository.findOneWithAuthoritiesByUsername(loginRequest.getUsername());
        user.get().setToken(AES256Cipher.encrypt(refreshTokenResponse.getId()));
        userRepository.save(user.get());

        // 3. return 객체 생성
        TokenResponse tokenResponse = TokenResponse.builder()
                                        .accessToken(accessToken)
                                        .refreshToken(refreshTokenResponse.getId())
                                        .authentication(authentication)
                                        .build();

        return tokenResponse;
    }
}

AccessToken 생성을 위해서 사용자ID, 비밀번호로 Authentication 객체 생성 후 TokenProvider.crateToken(authentication) 을 호출 하여서 AccessToken을 생성한다.

 

TokenProvider.createToken

package com.roopy.security.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;

/**
 *  토큰생성, 토큰정보조회, 유효성체크를 위한 클래스
 */
@Component
public class TokenProvider implements InitializingBean {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private static final String AUTHORITIES_KEY = "auth";

    private final String secret;

    private Key key;

    /**+
     * 기본생성자
     * 
     * @param secret set secret key from application.yml in jwt.secret
     */
    public TokenProvider(
            @Value("${jwt.secret}") String secret) {
        this.secret = secret;
    }

    /**+
     * 토큰 생성
     *
     * @param authentication set authentication object
     * @return token
     */
    public String createToken(Authentication authentication) {
        // 사용자의 권한 정보를 구분자를 콤마로 하여서 authorities 변수에 할당
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        Calendar c = Calendar.getInstance();

        // AccessToken 만료시간 30분 설정
        c.add(Calendar.MINUTE, 2);
        Date validity = c.getTime();

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        // 생성자에 주입받은 secret key decoding 처리
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        
        // decoding secret key key 변수에 할당
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }
}

RefreshToken 생성을 위해서 파라미터 생성 후 Redis 서버에 정보를 저장한다.

 

 

 

 

 

반응형

'Spring Security > Spring Boot Jwt with JPA and Redis' 카테고리의 다른 글

AccessToken 재발급 테스트  (0) 2021.07.02
AccessToken 재발급 프로세스  (0) 2021.06.28
로그인 테스트  (0) 2021.06.27
프로젝트 소개 및 설정  (0) 2021.06.27
Token Life Cycle And Cookie  (0) 2021.06.27