2021. 4. 11. 18:40ㆍSpring
쿠키와 세션
들어가기전에
인증을 먼저 알고가자!
- 인증은 Front-end 관점에서 봤을 때 사용자의 로그인, 회원가입과 같이 사용자의 도입부분을 가리키곤한다. 반면 Back-end 관점에서 봤을 때는 모든 API 요청에 대해 사용자를 확인하는 작업이다.
- 사용자A와 사용자 B가 앱을 사용한다고 가정하자. 두 사용자는 기본적을로 정보가 다르고 보유하고 있는 컨텐츠도 다르다. 따라서 서버에서는 A,B가 요청을 보냈을 때 누구의 요청인지를 정확히 알아야한다. 만일 그렇지 않다면 자신의 정보가 타인에게 유출되는 최악의 상황이 발생한다. 그렇기에 앱(Front-end) 에서는 자신이 누구인지를 알만한 단서를 서버에 보내야 하며, 서버(Back-end) 는 그 단서를 파악해 각 요청에 맞는 데이터를 뿌려주게 된다.
쿠키와 세션 방식
- Session/Cookie 방식의 인증은 기본적으로 세션 저장소를 필요로 합니다.(Redis를 많이 사용). 세션 저장소는 로그인을 했을 때 사용자의 정보를 저장하고 열쇠가 되는 세션 ID값을 만든다. 그리고 HTTP헤더에 실어 사용자에게 돌려보낸다.그러면 사용자는 쿠키로 보관하고 있다 인증이 필요한 요청에 쿠키(Session ID)를 넣어 보낸다. 웹 서버에서는 세션 저장소에 쿠키(Session ID)를 받고 저장되어 있는 정보와 매칭을 시켜 인증을 완료한다.
- Session은 서버에서 가지고 있는 정보이다. Cookie는 사용자에게 발급된 세션을 열기 위한 열쇠(Session ID)를 의미한다. 만약 쿠키만으로 인증을 사용한다는 말은 서버의 지원은 사용 x , 이는 즉 Client가 인증 정보를 책임지게 된다. 그렇게 되면 HTTP요청을 탈취당할 경우 다 털리게 된다.따라서 보안과는 상관없는 단순한 장바구니나 자동로그인 설정은 유용하게 쓰인다.
JWT란?
Session/Cookie 와 함께 모바일과 웹의 인증을 책임지는 대표주자이다. JWT는 Json Web Token의 약자로 인증에 필요한 정보들을 암호화시킨 토큰을 뜻한다. 위의 Session/Cookie 방식과 유사하게 사용자는 Access Token(JWT 토큰)을 HTTP 헤더에 실어 서버로 보내게 된다.
토큰을 만들기 위해서는 크게 3가지 Header, Payload, Verify Signature가 필요하다.
- Header : 위 3가지 정보를 암호화할 방식(alg), 타입(type) 등이 들어간다.
- Payload : 서버에서 보낼 데이터가 들어간다. 일반적으로 유저의 고유 ID값, 유효기간이 들어갑니다.
- Verify Signature : Base64 방식으로 인코딩한 Header, Payload 그리고 SECRET KEY를 더한 후 서명된다.
- 최종적인 결과 : Encoded Header + "." + Encoded Payload + "." + Verify Signature
Header, Payload는 인코딩될 뿐(16진수로 변경), 따로 암호화되지 않습니다. 따라서 JWT 토큰에서 Header, Payload는 누구나 디코딩하여 확인할 수 있습니다. 여기서 누구나 디코딩할 수 있다는 말은 Payload에는 유저의 중요한 정보(비밀번호)가 들어가면 쉽게 노출될 수 있다는 말이 됩니다.
하지만 Verify Signature는 SECRET KEY를 알지 못하면 복호화할 수 없습니다.
JWT 순서
저장된 토큰을 가지고 나의 정보 요청
이제 로그인이 되었고, 지금의 상태는 Local Storage에 토큰이 저장되어 있는 상태입니다.
이제 프런트엔드에서 토큰을 헤더에 담아 백엔드로 요청을 보내고(예시로 내 정보를 요청합니다), 백엔드에서는 토큰 안에 담긴 정보를 확인하여 그에 해당하는 응답을 내려줍니다.
여기서 주목해야 할 점은 백엔드에서 토큰 안에 담긴 정보를 확인하는 것입니다.
이 단계에서 Interceptor의 개념이 등장합니다.인터셉터는 요청이 Controller로 가기 전에 요청을 가로채 작업을 처리할 수 있습니다.
여기서는 요청 안에 토큰이 있는지 확인하고, 토큰 안에 있는 내용을 디코딩하여 요청안에 다시 넣어주는 작업을 합니다.
Interceptor와 대조되는 Filter라는 게 있는데, 자세한 설명은 생략하고 여기서는 Interceptor를 사용하도록 하겠습니다.
프로젝트에 적용하기
본 프로젝트는 React와 협업 프로젝트입니다.
로그인으로 토큰(Token) 발급
@Entity @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class User extends Timestamped { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "USER_ID") private Long id; @Column(nullable = false) private String username; @Column(nullable = false) private String password; @Column(nullable = false) private String email; @Lob private String profile_img; public User(UserLoginRequestDto requestDto) { this.username = requestDto.getUsername(); this.password = requestDto.getPassword(); } public User(String username,String password, String email) { this.username = username; this.password = password; this.email = email; }
}
### ex) Id = 1, name = "홍길동", pwd = "1234"의 유저가 현재 가입되어있는 상태라고 가정합니다.
fetch API 로 사용자가 입력한 **홍길동/1234**를 보내면 백엔드에서 생성한 토큰을 응답받아
> localStorage.setItem("jwt",token.accessToken)
명령의 통해 localStorage에 저장하는 구조입니다.
1.2 **Controller**
```java
@PostMapping("/api/login")
public Object userLogin(@Valid @RequestBody UserLoginRequestDto requestDto) {
if(!userService.checkUsernameAndPassword(requestDto)) {
return exceptionController.error("비밀번호가 일치하지 않습니다.");
}else {
String token = userService.createToken(requestDto);
return ResponseEntity.ok().body(new TokenResponse(token, "bearer"));
}
}
LoginRequest라는name과 pwd를 필드 변수로 가지고 있는 객체를 만들어 프런트에서 보낸 정보를 받아,
-> 일련의 과정(UserService.createToken)을 거쳐 토큰을 생성하고
-> 생성된 토큰을 TokenResponse 객체로 감싸 프런트로 보내줍니다.
TokenResponse
@Getter @Setter @AllArgsConstructor @NoArgsConstructor public class TokenResponse { private String accessToken; private String tokenType; }
1.3 JwtTokenProvider
@Component
public class JwtTokenProvider {
private String secretKey = "DANGGEON";
private long validityInMilliseconds = 30 * 60 * 1000L;
// public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") String secretKey, @Value("${security.jwt.token.expire-length}") long validityInMilliseconds) {
// this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
// this.validityInMilliseconds = validityInMilliseconds;
// }
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
//토큰생성
public String createToken(String subject) {
Claims claims = Jwts.claims().setSubject(subject);
Date now = new Date();
Date validity = new Date(now.getTime()
+ validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)//정보저장
.setIssuedAt(now) //토큰 발행 시간 정보
.setExpiration(validity)
.signWith(SignatureAlgorithm.HS512, secretKey.getBytes())// 사용할 암호화 알고리즘과 HS256
// signature 에 들어갈 secret값 세팅
.compact();
}
//토큰에서 값 추출
public String getSubject(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
//유효한 토큰인지 확인
public boolean validateToken(String token) {
// try {
// Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
// if (claims.getBody().getExpiration().before(new Date())) {
// return false;
// }
// return true;
// } catch (JwtException | IllegalArgumentException e) {
// return false;
// }
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
여기서 핵심은 jwtTokenProvider.createToken입니다.
jwt방식을 이용하여 토큰을 생성하려면 라이브러리를 추가해주어야 합니다.
build.gradle dependencies에 추가
dependencies { ... implementation 'io.jsonwebtoken:jjwt:0.9.1' }
그리고 많은 분들이 secret키와 expire를 properties에 했지만 저는 토큰을 생성해주는곳에 하는게 더 직관적이어서 여기에 했습니다. 이건 개인 차입니다!
-
이런식으로 token이 발급되는것을 볼 수 있습니다.!
토큰으로 내 정보 요청
2.1 프런트에서 내정보 요청
fetch("/info",{ method: 'get', headers: { 'content-type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem("jwt"), } }).then(res => res.json()) .then(json => alert("이름 : " + json.name+", 비밀번호 : " + json.pwd))
- 위의 코드는 /api/profile라는 주소로 요청을 보내고 있고, Controller의 구현의 다음과 같습니다.
@GetMapping("/api/profile")
public Object getUserFromToken(HttpServletRequest request) {
String username = (String) request.getAttribute("username");
User user = userImageService.findByName(username);
return user;
}
여기서 주목할 부분은 fetch API의 headers 부분입니다.
헤더에 'Authorization': 'Bearer ' + localStorage.getItem("jwt") 으로 토큰을 전달해 줍니다.
2.2 Interceptor
앞서 전체적인 흐름을 다룰 때 언급한 것처럼 요청이 controller에 도달하기 전에 Interceoptor로 요청을 가로채 header에 포함된 토큰의 내용을 디코딩하여 그 내용을 다시 요청으로 담아 controller 전달해 주어야 합니다.
그러기 위해서는 어떤 요청에 대해서 인터셉터 할지 interceptor를 등록을 해놓아야 합니다.
이때 WebMvcConfigurer를 이용합니다.
**WebMVCConfig.java**
```java
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
private final BearerAuthInterceptor bearerAuthInterceptor;
public WebMVCConfig(BearerAuthInterceptor bearerAuthInterceptor) {
this.bearerAuthInterceptor = bearerAuthInterceptor;
}
public void addInterceptors(InterceptorRegistry registry){
System.out.println(">>> 인터셉터 등록");
registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/profile");
registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/profile/update");
registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/boards");
}
}
```
WebMvcConfig.aaddInterceptors를 보면 '/info'라는 패턴으로 들어오는 요청에 대해서 bearerAuthInterceptor를 등록해주었습니다. 이렇게 하면 애플리케이션이 실행될 때 인터셉터를 등록하고 그 주소로 들어오는 요청을 기다리는 상태가 됩니다.
[![2021-04-10-5-40-39.png](https://i.postimg.cc/J0y6JnPg/2021-04-10-5-40-39.png)](https://postimg.cc/zHrFYJmS)
HandlerInterceptor를 implements 하여 구현합니다.
BearerAuthIntercepter.java
@Component public class BearerAuthInterceptor implements HandlerInterceptor { private AuthorizationExtractor authExtractor; private JwtTokenProvider jwtTokenProvider; public BearerAuthInterceptor(AuthorizationExtractor authExtractor, JwtTokenProvider jwtTokenProvider) { this.authExtractor = authExtractor; this.jwtTokenProvider = jwtTokenProvider; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { System.out.println(">>> interceptor.preHandle 호출"); String token = authExtractor.extract(request, "Bearer"); System.out.println(token); if (token == null || token.length() == 0) { return true; } if (!jwtTokenProvider.validateToken(token)) { throw new IllegalArgumentException("유효하지 않은 토큰"); } String username = jwtTokenProvider.getSubject(token); request.setAttribute("username", username); return true; } }
/api/profile 라는 주소로 요청을 보내서 interceptor 가 호출되면 interceptor는 preHandle 메서드를 호출합니다.
preHandle 메서드는 크게 3가지 동작을 합니다.
- request로부터 authExtractor.extract로 토큰을 추출
- jwtTokenProvider.getSubject로 토큰을 디코딩 - 위의 JwtTokenProvider 참고
- request.setAttribute로 요청에 디코딩한 값을 세팅
2.3 헤더에서 토큰 추출하기
AuthorizationExtractor.java
@Component public class AuthorizationExtractor { public static final String AUTHORIZATION = "Authorization"; public static final String ACCESS_TOKEN_TYPE = AuthorizationExtractor.class.getSimpleName() + ".ACCESS_TOKEN_TYPE"; public String extract(HttpServletRequest request, String type) { Enumeration<String> headers = request.getHeaders(AUTHORIZATION); while (headers.hasMoreElements()) { String value = headers.nextElement(); if (value.toLowerCase().startsWith(type.toLowerCase())) { return value.substring(type.length()).trim(); } } return Strings.EMPTY; } }
우리는 프런트에서 요청을 보낼 때 토큰을 headers의 'Authorization'이라는 Key로 담아 보냈습니다.
위의 코드를 간단히 설명하면 request의 헤더 중에 'Authorization' 항목의 값을 가져와서 그 안에 토큰 타입을 제외한 토큰 자체를 가져오는 로직입니다.
마지막...
헤더에 토큰을 담아 요청을 보냈고, 우리는 요청을 가로채 토큰을 확인한 다음 요청에 그 토큰이 의미하는 값을 담아주었습니다.
controller에서 그 값을 받아 응답을 내려주는 단계만 남았습니다.
@GetMapping("/api/profile") public Object getUserFromToken(HttpServletRequest request) { String username = (String) request.getAttribute("username"); User user = userImageService.findByName(username); return user; }
BearerAuthInterceptor.preHandle 메서드를 보면 HttpServletRequest라는 request에 name을 담아주고 있습니다. 따라서 controller에서 메서드 인자에 동일하게 'HttpServletRequest request'를 선언하여 getAttribute로 name의 값을 가져옵니다.
'Spring' 카테고리의 다른 글
[Spring] @Valid (0) | 2021.04.11 |
---|---|
[Spring] Bcrypt로 암호화하기 (0) | 2021.04.11 |
spring jpa localtime between (0) | 2021.03.23 |
Spring-JPA(@MappedSuperClass,@EntityListener) (0) | 2021.03.23 |
Spring으로 간단한 게시판 만들기 (0) | 2018.11.12 |