📢 여러 디바이스에서 동시 로그인이 가능한 경우, accesstoken 과 refreshtoken 값이 디바이스 별로 달라야 한다.
만약, 디바이스 별로 토큰값을 나누지 않는다면 어떤 디바이스에서 토큰이 만료되어 다시 토큰을 갱신받는 경우 다른 디바이스에 저장된 사용자의 토큰값은 갱신 전 값이므로 authorization 관련 에러가 뜰 수밖에 없다.
예를 들면, 웹에서 실컷 고생해서 게임에 이겼더니 게임 저장 API를 불러들이는 순간 모바일 디바이스에서 이미 토큰값이 갱신되어 게임데이터를 저장하지 못하게 되는 상황이 올 수 있다.
디바이스와 마찬가지로 여러 클라이언트에서 로그인을 요청하는 경우도 마찬가지이다.
아래부터는 디바이스별로 클라이언트가 다르다고 가정하고 클라이언트로 통일해서 설명한다.
여기서도 여러가지 방법을 달리 줄 수 있다!
- 경로 관리의 단순화: API 경로를 클라이언트별로 분리하면 API 경로 관리가 복잡해질 수 있습니다. 예를 들어, /api/mobile/users와 /api/web/users 등으로 나눌 경우, 두 개의 별도 경로를 관리해야 하며, 이는 유지 보수 측면에서 부담을 줍니다.
- 기능 중복 최소화: 같은 기능을 하는 API가 여러 버전으로 존재하면 코드 중복이 발생합니다. HTTP 헤더에 클라이언트 타입을 명시함으로써, 동일한 엔드포인트에서 서로 다른 동작을 처리할 수 있습니다.
- API 디자인 일관성 유지: RESTful API 디자인 원칙 중 하나는 URL은 리소스를 나타내고, 리소스에 대한 행동은 HTTP 메서드(GET, POST 등)으로 나타내는 것입니다. 따라서 클라이언트 타입에 따른 변화는 URL보다는 헤더나 본문 내용 등에서 반영하는 것이 좋습니다.
- 확장성과 유연성 증대: 미래에 새로운 클라이언트 타입이 추가되거나 기존의 것들이 변경되더라도 URL 구조를 변경하지 않고도 손쉽게 대응할 수 있습니다.
- 버전 관리 용이: 만약 모바일과 웹 각각 다른 버전의 API가 필요하다면 헤더 내부에서 처리 가능합니다. 이 방식은 각각 다른 경로(/v1/api, /v2/api)를 만드는 것보다 덜 혼란스럽고 관리하기 쉽습니다.
클라이언트에서 요청할 때, requestbody 값에 어떤 클라이언트 타입인지 명시해 두기
클라이언트에서 요청할 때, HTTP 헤더에 어떤 클라이언트 타입인지 값을 넣어 보내기
두 방법을 비교해 보자!
- 보안: HTTP 요청의 본문(body)은 일반적으로 로그나 오류 메시지 등에서 쉽게 노출될 수 있습니다. 반면에 헤더는 덜 노출되기 때문에, 토큰과 같은 보안 관련 정보를 보호하기 위해 사용됩니다.
- 분리된 Concerns(관심사): 요청 본문(body)은 일반적으로 비즈니스 로직에 관련된 데이터를 전송하는 데 사용되며, HTTP 헤더는 요청 자체와 관련된 메타데이터를 전달하는 데 사용됩니다. 인증 정보와 같은 것들은 요청의 메타데이터에 해당하므로, HTTP 헤더에 위치하는 것이 적절합니다.
- 표준화: Authorization 헤더는 HTTP 스펙에서 정의된 방식으로 인증 정보를 전달하기 위한 공식적인 방법입니다. 이 방식을 따르면 다른 개발자나 소프트웨어가 우리의 API를 더 쉽게 이해하고 사용할 수 있습니다.
- 성능: 모든 API 요청에서 인증 정보가 필요하지 않을 수도 있습니다(예: 공개적인 리소스). 만약 인증 정보가 항상 본문에 포함되어 있다면, 서버는 매번 본문을 파싱해야 하므로 부하가 커집니다. 반면에 헤더만 확인하면 되므로 처리가 빠르고 간편합니다.
따라서 JWT와 같은 인증 토큰을 Authorization 등의 HTTP 헤더에 넣어서 전송하는 것이 일반적인 관례입니다.
이 밖에도 다른 방법이 있겠지만 HTTP 헤더 클라이언트 타입값을 넣는 방식을 이용하기로 하였다.
아래는 간단하게 자바 스프링 코드로 작성해 보았다.
<import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtTokenProvider {
private final Map<String, String> secretKeys = new HashMap<>() {{
put("mobile", "your_mobile_secret_key");
put("web", "your_web_secret_key");
put("unity", "your_unity_secret_key");
}};
private final RedisTemplate<String, String> redisTemplate;
public JwtTokenProvider(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Map<String, String> createTokens(String username, String clientType) {
Claims claims = Jwts.claims().setSubject(username);
claims.put("clientType", clientType);
Date now = new Date();
// Access token with 1 hour validity
Date accessValidity = new Date(now.getTime() + 3600000); // 1 hour
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(accessValidity)
.signWith(SignatureAlgorithm.HS256, secretKeys.get(clientType))
.compact();
// Refresh token with 24 hours validity
Date refreshValidity = new Date(now.getTime() + 86400000); // 24 hours
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(refreshValidity)
.signWith(SignatureAlgorithm.HS256, secretKeys.get(clientType))
.compact();
// Store refresh token in Redis
redisTemplate.opsForValue().set(username + ":" + clientType + ":refreshToken", refreshToken);
Map<String,String> tokens=new HashMap<>();
tokens.put("accessToken",accessToken);
tokens.put("refreshToken",refreshToken);
return tokens;
}
public boolean validateAccessToken(String accessToken,String clientType){
try{
Jwts.parser().setSigningKey(secretKeys.get(clientType)).parseClaimsJws(accessToken);
return true;
}catch(Exception e){
return false;
}
}
public boolean validateRefreshToken(String username,String clientType){
try{
String storedRefreshToken=redisTemplate.opsForValue().get(username+":"+clientType+":refreshToken");
if(storedRefreshToken!=null){
Jwts.parser().setSigningKey(secretKeys.get(clientType)).parseClaimsJws(storedRefreshToken);
return true;
}else{
return false;
}
}catch(Exception e){
return false;
}
}
}
<import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AuthController(JwtTokenProvider jwtTokenProvider,
UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.jwtTokenProvider = jwtTokenProvider;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestParam("username") String username,
@RequestParam("password") String password,
HttpServletRequest request) {
// 유효한 사용자인지 확인하는 코드
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("Invalid credentials");
}
// 클라이언트 타입과 액세스 토큰을 헤더에서 추출
String clientType = request.getHeader("Client-Type");
String accessToken = request.getHeader("Authorization");
// JWT 토큰 생성
Map<String, String> tokens = jwtTokenProvider.createTokens(username, clientType);
return ResponseEntity.ok(tokens);
}
@GetMapping("/verify")
public ResponseEntity<String> verify(HttpServletRequest request) {
// 클라이언트 타입과 액세스 토큰을 헤더에서 추출
String clientType = request.getHeader("Client-Type");
String accessToken = request.getHeader("Authorization");
if (jwtTokenProvider.validateAccessToken(accessToken, clientType)) {
return ResponseEntity.ok("Valid Token");
} else {
return ResponseEntity.status(401).body("Invalid Token");
}
}
}