[Spring] 토큰 기반 인증을 구현해보자.

dohyun-dev·2023년 6월 19일
1

토큰 기반 인증을 구현해봅시다.

Dependencies

  • springboot:2.7.12
  • org.springframework.boot:spring-boot-starter-web
  • org.springframework.boot:spring-boot-starter-data-jpa
  • org.springframework.boot:spring-boot-starter-security (암호화를 위해 사용)
  • org.springframework.boot:spring-boot-starter-data-redis
  • org.mapstruct:mapstruct:1.5.3.Final
  • io.jsonwebtoken:jjwt-api:0.11.5
  • io.jsonwebtoken:jjwt-impl:0.11.5
  • io.jsonwebtoken:jjwt-jackson:0.11.5
  • com.h2database:h2
  • lombok

패키지 구성은 Layered Architecture로 구성하였고 이전 세션 기반 인증을 발전시켜서 작성해보겠습니다.

세션 기반 인증 보러가기

Auth Domain

JwtManager.class

package com.example.loginexample.domain.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;

@Component
public class JwtManager {

    public static final String HTTP_ONLY = "HttpOnly";
    public static final String ACCESS_TOKEN = "accessToken";
    public static final String REFRESH_TOKEN = "refreshToken";
    private final Key secretKey;
    private final long ACCESS_TOKEN_VALIDATION_MILLIS;
    private final long REFRESH_TOKEN_VALIDATION_MILLIS;
    private final JwtParser parser;

    public JwtManager(
            @Value("${jwt.secretKey}") String key,
            @Value("${jwt.accessTokenValidationMillis}") String accessTokenValidationMillis,
            @Value("${jwt.refreshTokenValidationMillis}") String refreshTokenValidationMillis
    ) {
        this.secretKey = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
        this.ACCESS_TOKEN_VALIDATION_MILLIS = Long.parseLong(accessTokenValidationMillis);
        this.REFRESH_TOKEN_VALIDATION_MILLIS = Long.parseLong(refreshTokenValidationMillis);
        this.parser = Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build();
    }

    public String createAccessToken(String memberId) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + ACCESS_TOKEN_VALIDATION_MILLIS);
        return Jwts.builder()
                .setSubject(memberId)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(secretKey)
                .compact();
    }

    public String createRefreshToken(String memberId) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALIDATION_MILLIS);
        return Jwts.builder()
                .setSubject(memberId)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(secretKey)
                .compact();
    }

    public String validTokenAndGetBody(String token) {
        if (token == null)
            return null;

        Claims claims = null;
        try {
            claims = (Claims) parser.parse(token).getBody();
        } catch (JwtException e) {
            e.printStackTrace();
            return null;
        }
        return claims.getSubject();
    }
}
  • 토큰을 생성하고 확인하는 클래스입니다.

JwtRepository.class

package com.example.loginexample.domain.auth;

public interface JwtRepository {

    String KEY = "refreshToken";

    void saveToken(String token);
    boolean isExistToken(String token);
    void deleteToken(String token);
}

JwtRepositoryV1.class

package com.example.loginexample.domain.auth;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class JwtRepositoryV1 implements JwtRepository {

    private final RedisTemplate<String, String> template;

    @Override
    public void saveToken(String token) {
        template.opsForSet().add(KEY, token);
    }

    @Override
    public boolean isExistToken(String token) {
        return template.opsForSet().isMember(KEY, token);
    }

    @Override
    public void deleteToken(String token) {
        template.opsForSet().remove(KEY, token);
    }
}
  • JwtRepositoryRedisSet 자료구조를 이용해 구성하였습니다.
  • 토큰을 저장하는 로직, 토큰을 확인하는 로직, 토큰을 지우는 로직으로 구성하였습니다.

AuthService.class

package com.example.loginexample.domain.auth;

public interface AuthService {
    void saveToken(String token);
    boolean isExistToken(String token);
    void deleteToken(String token);
}

AuthService.class

package com.example.loginexample.domain.auth;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthServiceV1 implements AuthService {

    private final JwtRepository jwtRepository;

    @Override
    public void saveToken(String token) {
        jwtRepository.saveToken(token);
    }

    @Override
    public boolean isExistToken(String token) {
        return jwtRepository.isExistToken(token);
    }

    @Override
    public void deleteToken(String token) {
        jwtRepository.deleteToken(token);
    }
}

AuthMemberFacadeImpl

package com.example.loginexample.facade;

import com.example.loginexample.domain.auth.AuthService;
import com.example.loginexample.domain.auth.JwtManager;
import com.example.loginexample.domain.member.MemberReadService;
import com.example.loginexample.dto.AuthenticationDto;
import com.example.loginexample.dto.MemberDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthMemberFacadeImpl implements AuthMemberFacade {

    private final MemberReadService memberReadService;
    private final AuthService authService;
    private final JwtManager jwtManager;
    private final PasswordEncoder passwordEncoder;

    @Override
    public MemberDto loginSession(String email, String password) {
        MemberDto findMember = memberReadService.findMemberByEmail(email);
        if (passwordEncoder.matches(password, findMember.getPassword()))
            return findMember;
        return null;
    }

    @Override
    public AuthenticationDto loginJwt(String email, String password) {
        MemberDto findMember = memberReadService.findMemberByEmail(email);
        if (passwordEncoder.matches(password, findMember.getPassword()))
            return createAuthentication(findMember.getMemberId());
        return null;
    }

    @Override
    public String tokenValidate(String token) {
        return jwtManager.validTokenAndGetBody(token);
    }

    @Override
    public AuthenticationDto reissueToken(String memberId) {
        AuthenticationDto authentication = createAuthentication(memberId);
        authService.saveToken(authentication.getRefreshToken());
        return authentication;
    }

    private AuthenticationDto createAuthentication(String memberId) {
        String accessToken = jwtManager.createAccessToken(memberId);
        String refreshToken = jwtManager.createRefreshToken(memberId);

        AuthenticationDto authenticationDto = AuthenticationDto.builder()
                .memberId(memberId)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();

        return authenticationDto;
    }

    @Override
    public void deleteRefreshToken(String token) {
        authService.deleteToken(token);
    }
}
  • loginJwt : email과 password를 확인해 토큰을 발급하는 메서드입니다.
  • tokenValidate : 토큰의 위변조 여부와 유효기간을 확인하는 메서드입니다.
  • reissueToken : 엑세스 토큰과 리프레시 토큰을 재발급 하는 메서드입니다.
  • createAuthentication : 엑세스 토큰과 리프레시 토큰을 생성하는 메서드입니다.
  • deleteRefreshToken : 리프레시 토큰을 삭제하는 메서드입니다.

Presentation Layer

LoginCotroller.class

import static com.example.loginexample.domain.auth.JwtManager.ACCESS_TOKEN;
import static com.example.loginexample.domain.auth.JwtManager.REFRESH_TOKEN;
import static com.example.loginexample.presentation.auth.AuthMessage.*;

@RestController
@RequiredArgsConstructor
public class LoginController {

    private final AuthMemberFacade authMemberFacade;

    @PostMapping("/loginV1")
    public ResponseEntity<ResponseDto> loginV1(@RequestBody LoginDto loginDto, HttpServletRequest request) {

        MemberDto loginMember = authMemberFacade.loginSession(loginDto.getEmail(), loginDto.getPassword());

        // 로그인 실패
        if (loginMember == null)
            throw new LoginFailureException();

        // 로그인 성공
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
        ResponseDto responseDto = ResponseDto.builder()
                .body(LOGIN_SUCCESS.message)
                .build();

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(responseDto);
    }

    @PostMapping("/loginV2")
    public ResponseEntity<ResponseDto> loginV2(@RequestBody LoginDto loginDto, HttpServletResponse response) {

        AuthenticationDto loginMember = authMemberFacade.loginJwt(loginDto.getEmail(), loginDto.getPassword());

        // 로그인 실패
        if (loginMember == null)
            throw new LoginFailureException();

        // 로그인 성공
        // 토큰 쿠키 설정
        setTokenCookies(response, loginMember);

        ResponseDto responseDto = ResponseDto.builder()
                .body(LOGIN_SUCCESS.message)
                .build();

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(responseDto);
    }

    @PostMapping("/logoutV1")
    public ResponseEntity<ResponseDto> logoutV1(HttpServletRequest request) {
        request.getSession().invalidate();

        ResponseDto responseDto = ResponseDto.builder()
                .body(LOGOUT.message)
                .build();

        return ResponseEntity
                .status(HttpStatus.OK)
                .body(responseDto);
    }

    @PostMapping("/logoutV2")
    public ResponseEntity<ResponseDto> logoutV2(
            @CookieValue(name = "accessToken", required = false) Cookie accessTokenCookie,
            @CookieValue(name = "refreshToken", required = false) Cookie refreshTokenCookie,
            HttpServletResponse response
    ) {

        List<Cookie> cookies = new ArrayList<>();

        // 엑세스 토큰 쿠키 삭제
        if (accessTokenCookie != null) {
            accessTokenCookie.setMaxAge(0);
            cookies.add(accessTokenCookie);
        }

        // 리프레시 토큰 쿠키 삭제
        if (refreshTokenCookie != null) {
            refreshTokenCookie.setMaxAge(0);
            authMemberFacade.deleteRefreshToken(refreshTokenCookie.getValue());
            cookies.add(refreshTokenCookie);
        }

        // 쿠키 추가
        cookies.stream().forEach(cookie -> response.addCookie(cookie));

        ResponseDto responseDto = ResponseDto.builder()
                .body(LOGOUT.message)
                .build();

        return ResponseEntity
                .status(HttpStatus.OK)
                .body(responseDto);
    }

    @GetMapping("/is-loginV1")
    public ResponseEntity<ResponseDto> isLoginV1(HttpServletRequest request) {
        HttpSession session = request.getSession(false);

        ResponseDto responseDto = ResponseDto
                .builder()
                .body((session != null) ? IS_LOGIN_TRUE.message : IS_LOGIN_FALSE.message)
                .build();

        return ResponseEntity
                .ok()
                .body(responseDto);
    }

    @GetMapping("/is-loginV2")
    public ResponseEntity<ResponseDto> isLoginV2(
            @CookieValue(name = "accessToken", required = false) Cookie accessTokenCookie,
            @CookieValue(name = "refreshToken", required = false) Cookie refreshTokenCookie,
            HttpServletResponse response
    ) {

        Object message = null;

        if (accessTokenCookie == null)
            message = IS_LOGIN_FALSE.message;
        else {
            String memberId = authMemberFacade.tokenValidate(accessTokenCookie.getValue());
            if (memberId != null)

                message = IS_LOGIN_TRUE.message;
            else if (
                    memberId != null &&
                            refreshTokenCookie != null &&
                            authMemberFacade.tokenValidate(refreshTokenCookie.getValue()) != null
            ) {

                AuthenticationDto authenticationDto = authMemberFacade.reissueToken(memberId);
                setTokenCookies(response, authenticationDto);
                message = REISSUE_TOKEN;
            } else

                message = INVALID_TOKEN.message;
        }

        ResponseDto responseDto = ResponseDto
                .builder()
                .body(message)
                .build();

        return ResponseEntity
                .ok()
                .body(responseDto);
    }

    private void setTokenCookies(HttpServletResponse response, AuthenticationDto loginMember) {
        // 엑세스 토큰 쿠키 설정
        Cookie accessTokenCookie = new Cookie(ACCESS_TOKEN, loginMember.getAccessToken());
        accessTokenCookie.setHttpOnly(true);

        // 리프레쉬 토큰 쿠키 설정
        Cookie refreshTokenCookie = new Cookie(REFRESH_TOKEN, loginMember.getAccessToken());
        refreshTokenCookie.setHttpOnly(true);

        // 토큰 추가
        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);
    }
}
  • loginV2
    • authMemberFacade.loginDto 을 통해 AuthenticationDto를 반환받습니다.
    • AuthenticationDto가 null이라면 LoginFailureException 을 발생시킵니다.
    • 로그인 성공이라면 생성된 토큰을 쿠키로 반환해줍니다.
  • logoutV2
    • 엑세스 토큰, 리프레시 토큰 쿠키를 삭제합니다.

0개의 댓글