인증과 인가

강서진·2024년 4월 4일

인증과 인가는 항상 붙어 다니나, 둘이 가리키는 것은 다르다.

Authentication 인증

사용자 또는 시스템의 신원을 확인하는 과정으로, 사용자나 시스템이 실제로 DB에 저장된 사용자나 시스템인지를 확인하는 과정이다. 현재 요청을 보내는 사람이 누구인지를 대답하는 것이다.
인증에 사용되는 매커니즘은 사용자이름과 비밀번호를 사용하는 것이 가장 일반적이며, 나아가 생체 인식 데이터, 보안 토큰, 또는 디지털 인증서와 같은 자격 증명을 사용할 수도 있다.
성공적인 인증은 사용자나 시스템에 자원에 대한 액세스 권한을 부여한다.

Authorization 인가

인가는 권한 부여라고도 할 수 있다. 인증된 사용자나 시스템이 어떤 작업이나 자원에 액세스할 수 있는지를 결정하는 것을 말하며, 허용되지 않는 사용자가 자원에 액세스하는 것을 방지할 수 있다. 따라서 인가는 인증이 선행되어야 하며, 인증된 신원 및 속성을 기반으로 액세스 권한이 부여되거나 거부될 수 있다.
인증과 달리 인가는 요청을 보내는 사람이 무엇을 할 수 있는지를 대답하는 문제이다.
권한은 역할, 권한 또는 액세스 제어 목록(ACL)과 같은 기준을 사용한다.

ACL(Access Control List):
사용자 또는 그룹에게 부여된 액세스 권한과 자원에서 수행할 수 있는 작업이 명시된 목록을 말한다. 각 항목은 일반적으로 주체(=사용자나 그룹), 권한집합(=읽기, 쓰기, 실행 등), 추가적으로 시간제한이나 제한조건 등의 속성으로 구성된다.
사용자나 시스템이 자원에 액세스를 시도하면, 해당 자원의 ACL이 검사된다. 접근한 주체에게 연결된 권한에 따라 해당 자원의 액세스가 허용되는지, 거부되는지가 결정된다.


예시/ 미니 프로젝트

미니 프로젝트에서 회원가입과 로그인을 구현하면서 인증과 인가를 간단하게 다루어보았다. 리액트로 만든 프론트와 연결하는 관계로 session이 유지되지 않았기 때문에 stateless인 JWT를 발급하여 사용하는 것으로 하였다.

  • 회원가입:
    • username(이메일), 비밀번호, 이름을 입력받는다.
    • 입력받은 MemberRegisterRequest 객체를 member entity로 변형하여 DB에 저장한다.
      이 때 비밀번호는 BCryptPasswordEncoder를 사용하여 암호화된 비밀번호만 저장한다.
  • 로그인
    • username(이메일), 비밀번호를 입력받는다.
    • 입력받은 MemberLoginRequest 객체로 DB에서 일치하는 username을 가진 사용자를 찾는다.
    • 입력받은 비밀번호를 암호화한 것과 DB에 저장된 암호화된 비밀번호가 일치하면 토큰을 발행한다.
      • AccessToken에는 username과 type(권한)을 저장한다.
      • RefreshToken에는 만료기간만 저장한다.
    • 발급된 Access Token과 Refresh Token을 response로 반환한다.
    • 프론트에서는 받은 토큰을 로컬 스토리지에 저장하고, 요청을 보낼 때마다 헤더에 액세스 토큰을 담아 보낸다.
    • 서버에서는 매 요청마다 헤더에 담긴 액세스 토큰을 확인하여 로그인 된 유저의 정보를 유지한다.
  • Access Token 만료 시
    • 서버에서 Access Token의 내용을 확인한다.
    • 만료시간이 지났으면 ExpiredJWTException이 발생한다.
    • 해당 예외가 발생하면 필터에서 헤더에 담긴 Refresh Token을 찾는다. 없으면 토큰 오류 객체가 담긴 응답이 프론트에 반환된다.
    • Access Token이 만료된 경우, 프론트에서는 만료된 Access Token과 Refresh Token을 헤더에 담아 요청을 보낸다.
    • Refresh Token이 있으면 해당 토큰의 내용을 확인하고, Refresh Token의 만료시간이 아직 지나지 않았다면 새로운 Access Token이 발급된다. 이 새로운 Access Token은 응답 헤더에 담겨 반환된다.
  • 로그아웃
    • 로그아웃 시 DB에서 해당 유저의 RefreshToken을 삭제한다.

JWT를 검증하는 필터를 만들어 SecurityConfig에 추가하여 사용하였다.
다만 아쉽게도 Refresh Token을 사용한 Access Token 재발급은 서버 쪽에서 구현이 되어 있으나, 프론트 쪽에서 만료시 토큰을 보내는 것이 시간상 구현이 힘들어 액세스 토큰의 시간을 조금 길게 잡는 것으로 마무리짓게 되었다.
만약 JWT를 쿠키에 담아 전달했더라면 프론트에서도 조금 더 토큰을 다루기 쉬웠을 것 같다. 하여 회고 기간에는 쿠키를 사용해보려고 한다.


코드

MemberEntity

@Entity(name="member")
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
@Data
@EqualsAndHashCode(callSuper = true)
@EntityListeners(AuditingEntityListener.class)
public class MemberEntity extends BaseEntity{

    // email
    @Column(nullable = false, length = 50)
    private String username;

    @Column(nullable = false, length = 100)
    private String password;

    @Column(nullable = false, length = 50)
    private String name;

    @Column(nullable = false, length = 50)
    @Enumerated(value = EnumType.STRING)
    private MemberStatus status;

    @Column(nullable = false, length = 45)
    @Enumerated(value = EnumType.STRING)
    private MemberType type;

    private String profileImage;

    @LastModifiedDate
    private LocalDateTime modifiedAt;

type은 권한을 설정해둔 Enum으로, 사용자측 서비스만 구상하였으므로 현재는 USER밖에 없다.
status는 추후 회원탈퇴할 경우를 고려하여 추가한 Enum이다.

JwtProvider

@Component
@RequiredArgsConstructor
public class JwtProvider {

    @Value("${token.accessToken.plus-hour}")
    private Long ACCESS_TOKEN_PLUS_HOUR;
    @Value("${token.accessToken.plus-minute}")
    private Long ACCESS_TOKEN_PLUS_MINUTE;
    @Value("${token.refreshToken.plus-hour}")
    private Long REFRESH_TOKEN_PLUS_HOUR;
    // TODO: PostConstruct도 고려
    @Value("${token.secret.tokenKey}")
    private String SECRET_KEY;

    private final ObjectMapper objectMapper;
    private final RefreshTokenRepository refreshTokenRepository;


    // 만료시간(토큰 발급 시간(현재) + n시간) 생성하는 메서드
    public Date generateTime(LocalDateTime localDateTime) {
        return Date.from(localDateTime.atZone(
                ZoneId.systemDefault()
        ).toInstant());
    }

    // Access Token 발행 메서드
    public TokenDto generateAccessToken(Map<String, Object> data) {
        String base64EncodedKey = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
        SecretKey key = Keys.hmacShaKeyFor(base64EncodedKey.getBytes());

        LocalDateTime accessExpiredAt = LocalDateTime.now().plusMinutes(ACCESS_TOKEN_PLUS_MINUTE);
        String accessToken = Jwts.builder()
                .signWith(key)
                .setClaims(data)
                .setExpiration(generateTime(accessExpiredAt))
                .compact();

        return TokenDto.builder()
                .token(accessToken)
                .expiredAt(accessExpiredAt)
                .build();
    }

    // Refresh Token 발행 메서드
    // 민감한 정보 포함 x
    public TokenDto generateRefreshToken() {
        String base64EncodedKey = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
        SecretKey key = Keys.hmacShaKeyFor(base64EncodedKey.getBytes());

        LocalDateTime refreshExpiredAt = LocalDateTime.now().plusHours(REFRESH_TOKEN_PLUS_HOUR);
        String refreshToken = Jwts.builder()
                .signWith(key)
                .setExpiration(generateTime(refreshExpiredAt))
                .compact();
        return TokenDto.builder()
                .token(refreshToken)
                .expiredAt(refreshExpiredAt)
                .build();
    }

    // 토큰 검증 메서드
    // 문자열 토큰을 집어넣으면 decode 하여 map 형태로 반환
    public Map<String, Object> validateTokenAndThrow(String token) {
        String base64EncodedKey = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
        SecretKey key = Keys.hmacShaKeyFor(base64EncodedKey.getBytes());

        JwtParser parser = Jwts.parserBuilder()
                .setSigningKey(key)
                .build();

        try {
            Jws<Claims> result = parser.parseClaimsJws(token);
            return new HashMap<String, Object>(result.getBody());
        } catch (Exception e) {
            if (e instanceof SignatureException) {
                throw new SignatureException("서명 오류");
            } else if (e instanceof ExpiredJwtException) {
                throw new ExpiredJwtException(null,null,"토큰 만료");
            } else {
                throw new RuntimeException("예외발생");
            }
        }
    }

    // JWT 해석(만료/유효성 체크 x)
    public Map<String, Object> decodeTokenAndThrow(String token) {
        String[] tokenParts = token.split("\\.");
        String decodedClaim = new String(
                Base64.getDecoder().decode(tokenParts[1]),
                StandardCharsets.UTF_8);
        try {
            Map map = objectMapper.readValue(decodedClaim, Map.class);
            return map;
        } catch (JsonProcessingException e) {
            throw new ResultException(TokenErrorCode.TOKEN_EXCEPTION);
        }
    }

    public void validateRefreshToken(String refreshToken) {
        // 전달받은 refresh token으로 DB에 저장된 refresh token과 일치하는 entity 가져옴
        RefreshTokenEntity refreshTokenEntity = refreshTokenRepository
                .findFirstByToken(refreshToken)
                .orElseThrow(() -> new ResultException(TokenErrorCode.AUTHORIZATION_TOKEN_NOT_FOUND));

        // refresh token이 만료되었는지, 유효한지 체크
        validateTokenAndThrow(refreshToken);
    }

    public TokenDto reissueAccessToken(String expiredAccessToken) {
        Map<String, Object> userData = decodeTokenAndThrow(expiredAccessToken);

        // access token에 담겨있던 내용 그대로 다시 생성
        // 담긴 내용: username, type
        // -> 변경되지 x
        TokenDto reissuedAccessToken = generateAccessToken(userData);
        return reissuedAccessToken;
    }

    public void invalidateRefreshToken(Long memberId) {

        // 서버 측에 저장된 jwt를 삭제
        Optional<RefreshTokenEntity> refreshTokenEntity = refreshTokenRepository
                .findFirstByMemberId(memberId);

        refreshTokenEntity.ifPresent(refreshTokenRepository::delete);
    }
}

JwtAuthenticationFilter

package api.config.auth;

import api.common.error.TokenErrorCode;
import api.common.exception.ResultException;
import api.config.jwt.JwtProvider;
import api.config.jwt.TokenDto;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Order(0)
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    /** 헤더에서 토큰 받아오기 **/
    private String parseBearerToken(HttpServletRequest request, String headerName) {
        return Optional.ofNullable(request.getHeader(headerName))
                .filter(token -> token.startsWith("Bearer "))
                .map(token -> token.substring(7))
                .orElse(null);
    }

    /** 토큰에서 user 정보 받아오기 **/
    private User parseUserFromToken(String token){
        Map<String, Object> parsedToken = Optional.ofNullable(token)
                .map(jwtProvider::validateTokenAndThrow)
                .orElse(Map.of("username",
                        "anonymous",
                        "type",
                        "anonymous"));

        return createUser(parsedToken);
    }

    private User createUser(Map<String, Object> map){
        String username = (String) map.get("username");
        String type = (String) map.get("type");

        return new User(username, "", List.of(new SimpleGrantedAuthority(type)));
    }

    /** access token 재발행
     * 1) refresh token 받아오기
     * 2) refresh token 검증
     * 3) refresh token 으로 access token 재발행
     * */
    private String issueAccessTokenByRefreshToken(HttpServletRequest request,
                                           HttpServletResponse response){
        try{
            String refreshToken = parseBearerToken(request, "RefreshToken");
            if (refreshToken==null){
                throw new ResultException(TokenErrorCode.EXPIRED_TOKEN, "refresh token 필요");
            }

            // refresh token 검증
            jwtProvider.validateRefreshToken(refreshToken);

            // 만료된 access token으로 새 token 발행
            String expiredAccessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION);
            TokenDto newAccessToken = jwtProvider.reissueAccessToken(expiredAccessToken);

            return newAccessToken.getToken();
        } catch (Exception e) {
            throw new ResultException(TokenErrorCode.TOKEN_EXCEPTION);
        }
    }

    private void storeAuthenticationInContext(HttpServletRequest request, String token){
        User user = parseUserFromToken(token);
        AbstractAuthenticationToken authenticated =
                UsernamePasswordAuthenticationToken.
                        authenticated(
                                user,
                                token,
                                user.getAuthorities());
        authenticated.setDetails(new WebAuthenticationDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticated);
    }

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


        try{
            String token = parseBearerToken(request, HttpHeaders.AUTHORIZATION);
            storeAuthenticationInContext(request, token);

            // 토큰 만료 시 Refresh Token 확인하여 Access Token 재발급
        } catch (ExpiredJwtException e){

            String newAccessToken = issueAccessTokenByRefreshToken(request, response);
            storeAuthenticationInContext(request, newAccessToken);

            // 헤더에 New-Access-Token으로 새 access token 전달
            response.setHeader("NewAccessToken",newAccessToken);

            // 유효하지 않은 토큰 혹은 그 외 오류 발생 시 로그아웃처리, 다시 로그인하도록 유도
        } catch (Exception e){
            // printStackTrace는 개발 단계에서만 사용
//            e.printStackTrace();

            throw new ResultException(TokenErrorCode.TOKEN_EXCEPTION);
        }

        filterChain.doFilter(request, response);
    }
}

0개의 댓글