인증과 인가는 항상 붙어 다니나, 둘이 가리키는 것은 다르다.
사용자 또는 시스템의 신원을 확인하는 과정으로, 사용자나 시스템이 실제로 DB에 저장된 사용자나 시스템인지를 확인하는 과정이다. 현재 요청을 보내는 사람이 누구인지를 대답하는 것이다.
인증에 사용되는 매커니즘은 사용자이름과 비밀번호를 사용하는 것이 가장 일반적이며, 나아가 생체 인식 데이터, 보안 토큰, 또는 디지털 인증서와 같은 자격 증명을 사용할 수도 있다.
성공적인 인증은 사용자나 시스템에 자원에 대한 액세스 권한을 부여한다.
인가는 권한 부여라고도 할 수 있다. 인증된 사용자나 시스템이 어떤 작업이나 자원에 액세스할 수 있는지를 결정하는 것을 말하며, 허용되지 않는 사용자가 자원에 액세스하는 것을 방지할 수 있다. 따라서 인가는 인증이 선행되어야 하며, 인증된 신원 및 속성을 기반으로 액세스 권한이 부여되거나 거부될 수 있다.
인증과 달리 인가는 요청을 보내는 사람이 무엇을 할 수 있는지를 대답하는 문제이다.
권한은 역할, 권한 또는 액세스 제어 목록(ACL)과 같은 기준을 사용한다.
ACL(Access Control List):
사용자 또는 그룹에게 부여된 액세스 권한과 자원에서 수행할 수 있는 작업이 명시된 목록을 말한다. 각 항목은 일반적으로 주체(=사용자나 그룹), 권한집합(=읽기, 쓰기, 실행 등), 추가적으로 시간제한이나 제한조건 등의 속성으로 구성된다.
사용자나 시스템이 자원에 액세스를 시도하면, 해당 자원의 ACL이 검사된다. 접근한 주체에게 연결된 권한에 따라 해당 자원의 액세스가 허용되는지, 거부되는지가 결정된다.
미니 프로젝트에서 회원가입과 로그인을 구현하면서 인증과 인가를 간단하게 다루어보았다. 리액트로 만든 프론트와 연결하는 관계로 session이 유지되지 않았기 때문에 stateless인 JWT를 발급하여 사용하는 것으로 하였다.
JWT를 검증하는 필터를 만들어 SecurityConfig에 추가하여 사용하였다.
다만 아쉽게도 Refresh Token을 사용한 Access Token 재발급은 서버 쪽에서 구현이 되어 있으나, 프론트 쪽에서 만료시 토큰을 보내는 것이 시간상 구현이 힘들어 액세스 토큰의 시간을 조금 길게 잡는 것으로 마무리짓게 되었다.
만약 JWT를 쿠키에 담아 전달했더라면 프론트에서도 조금 더 토큰을 다루기 쉬웠을 것 같다. 하여 회고 기간에는 쿠키를 사용해보려고 한다.
@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이다.
@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);
}
}
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);
}
}