티스토리 뷰

IT/개발

Spring + JWT

K.Nero 2018. 8. 31. 16:48

앱을 만들면서 데이터를 가져오는 서버를 만들게 되었다. 처음에는 스프링의 세션을 사용하여 사용자 정보를 저장했지만 간단한 사용자 정보만 필요했기 때문에 JWT(http://bcho.tistory.com/999)를 사용하기로 했다. 스프링에서 적용방법을 찾다 보니 대부분이 OAuth와 같이 사용하는 방법이 대부분 xml 을 사용한 설명만 있었다. 그래서 annotation 을 사용한 JWT 설정하는 방법을 남긴다. (참고 사이트 : https://www.toptal.com/java/rest-security-with-jwt-spring-security-and-java)

1. 요청을 가로챌 수 있는 filter 를 만든다.

@Slf4j
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private Set<String> ignorePath = new HashSet<>();

public JwtAuthenticationFilter() {
super("/**");
ignorePath.add("POST/user/login");
ignorePath.add("POST/user");
ignorePath.add("GET/review");
}

@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
String method = request.getMethod();
String uri = request.getRequestURI();
return !ignorePath.contains(method + uri);
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
throw new JwtTokenMissingException("No JWT token found in request headers");
}

String authToken = header.substring(7);
JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken);

return getAuthenticationManager().authenticate(authRequest);
}

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
super.successfulAuthentication(request, response, chain, authResult);

// As this authentication is in HTTP header, after success we need to continue the request normally
// and return the response as if the resource was not secured at all
chain.doFilter(request, response);
}
}

ignorePath 는 인증이 필요없는 사이트의 method 와 uri 를 등록하여 requiresAuthentication 에서 검사 유무를 사용할 때 사용한다. (rest 에서 사용하기 때문에 method 도 사용하였다.) header 의 token 을 꺼내 JwtAuthenticationToken 에 저장하고 넘겨 준다. 여기서 getAuthenticationManager 와 JwtAuthenticationToken 은 아래와 같이 만들어 주었다.

2. JwtAuthenticationToken은 단순히 토큰을 전달해 주는 용도이며 getPrincipal() 를 통해서 토큰을 꺼내도록 하였다.

public class JwtAuthenticationToken implements Authentication {
private String authToken;

JwtAuthenticationToken(String authToken) {
this.authToken = authToken;
}

@Override
public Object getPrincipal() {
return authToken;
}

@Override
public boolean implies(Subject subject) {
return false;
}

@Override
public String getName() {
return null;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}

@Override
public Object getCredentials() {
return null;
}

@Override
public Object getDetails() {
return null;
}

@Override
public boolean isAuthenticated() {
return false;
}

@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {

}
}

3. 위 필터에 전달해준 authRequest 를 받아서 header 의 토큰을 통해서 AuthenticationUser 를 만든다.

@Component
public class JwtAuthenticationManager implements AuthenticationManager {
@Autowired
private JwtGenerator jwtGenerator;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
String token = (String) jwtAuthenticationToken.getPrincipal();

AuthenticatedUser user = jwtGenerator.parseToken(token);
if (user == null) {
throw new JwtTokenMalformedException("JWT token is not valid");
} else if (!user.isAuthenticated()) {
throw new JwtTokenMalformedException("required field is wrong.");
}

return user;
}
}

4. 토큰을 파싱하거나 생성하는 jwtGenerator 는 jjwt 를 사용하였다.

compile 'io.jsonwebtoken:jjwt-api:0.10.5'
runtime 'io.jsonwebtoken:jjwt-impl:0.10.5',
// Uncomment the next line if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms:
//'org.bouncycastle:bcprov-jdk15on:1.60',
'io.jsonwebtoken:jjwt-jackson:0.10.5'
@Slf4j
@Component
public class JwtGenerator {
@Value("${jwt.secret}")
private String secret;

@Value("${jwt.expireHour}")
private int expireHour;

private Key jwtSecretKey;
private int expireTime;

@PostConstruct
public void init() {
jwtSecretKey = Keys.hmacShaKeyFor(secret.getBytes());
expireTime = 1000 * 60 * 60 * expireHour;
}

/**
* Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token).
* If unsuccessful (token is invalid or not containing all required user properties), simply returns null.
*
* @param token the JWT token to parse
* @return the User object extracted from specified token or null if a token is invalid.
*/
AuthenticatedUser parseToken(String token) {
try {
// List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole());
Claims body = Jwts.parser().setSigningKey(jwtSecretKey).parseClaimsJws(token).getBody();
return new AuthenticatedUser(body.getId(), (String) body.get("nickname"));

} catch (ExpiredJwtException e) {
log.error(e.getMessage());
} catch (JwtException | ClassCastException e) {
log.error("jwt parse error.", e);
}

return null;
}

/**
* Generates a JWT token containing username as subject, and userId and role as additional claims.
* These properties are taken from the specified User object.
* Tokens validity is infinite.
*
* @param user the user for which the token will be generated
* @return the JWT token
*/
public String generateToken(UserDto user) {
Claims claims = Jwts.claims().setId(user.getEmail());
claims.setExpiration(new Date(System.currentTimeMillis() + expireTime));
claims.put("nickname", user.getNickname());

return Jwts.builder()
.setClaims(claims)
.signWith(jwtSecretKey, SignatureAlgorithm.HS512)
.compact();
}
}

여기서 secret를 사용하여 hash 키를 암호하였으며 expireHour 를 사용하여 만료시간 설정을 하였다. jjwt 가 파싱을 하면서 자동으로 만료시간을 체크하여 예외를 발생시켜 주므로 따로 체크할 필요는 없다.

5. 토큰으로 생성되는 AuthenticatedUser 는 아래와 갔다.

public class AuthenticatedUser implements Authentication {
private String email;
private String nickname;

AuthenticatedUser(String email, String nickname) {
this.email = email;
this.nickname = nickname;
}

@Override
public String getName() {
return this.nickname;
}

@Override
public Object getPrincipal() {
return this.email;
}

@Override
public boolean isAuthenticated() {
return !StringUtils.isEmpty(email) && !StringUtils.isEmpty(nickname);
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}

@Override
public boolean implies(Subject subject) {
return false;
}

@Override
public Object getCredentials() {
return null;
}

@Override
public Object getDetails() {
return null;
}

@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {

}
}

interface 가 제공하는 메소드를 사용하여 정보를 꺼내도록 하였다.

6. 이렇게 생성된 filter 를 config class 에 추가해 준다.

@Configuration
public class SecurityConfig {
@Autowired
private JwtAuthenticationManager jwtAuthenticationManager;

@Bean
public Filter jwtAuthenticationFilter() {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
filter.setAuthenticationManager(jwtAuthenticationManager);
// We do not need to do anything extra on REST authentication success, because there is no page to redirect to
filter.setAuthenticationSuccessHandler((request, response, authentication) -> {});

return filter;
}
}

7. 그리고 JwtGenerator 에서 사용할 설정을 application.yaml 에 추가해 준다.

jwt:
secret: 5bc9b21a28f64739a0c25e02aeb7bc82419e8c46175e415e8563487ec932aadb
expireHour: 24

이렇게 되면 header 에 Authorization 에 "Bearer eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiJrd29uLXMtbUBoYW5tYWlsLm5ldCIsImV4cCI6MTUzNTUzMzgwNywibmlja25hbWUiOiJLTmVybyJ9.-42Ual2ck_lN73k6khAirezRg5kYlvXV0GD-c67aspkNACQ31CUZklZ3JNljXtK9i38wpvXhivhzVUg23T_6sw" 와 같은 값이 없다면 401 을 반환한다.

마지막으로 controller 에서 요청을 받았을 경우 jwt 에 저장된 사용자 정보가 필요하게 되는데 이 경우에는 아래와 같이 꺼낸다.

AuthenticatedUser user = (AuthenticatedUser) SecurityContextHolder.getContext().getAuthentication();

유의사항

위에서 사용한 jjwt 는 기본 적으로 Encoder 로 URL Safe Base64 를 사용하게 된다. URL Safe 는 Base64의 특수 문자인 + / 이 두 개를 - _ 로 변경해서 인코딩 되는데 이는 URL 인코딩에 의해서 변경되는 것을 막아준다. (+ / 두 문자는 URL 인코딩 해 보면 %2B %2F 로 인코딩 된다.)
만약 URL Safe 를 사용하고 싶지 않다면 아래와 같이 Encoder 를 지정해 준다.
String token = Jwts.builder()
.setClaims(claims)
.base64UrlEncodeWith(Encoders.BASE64) // -> 이 부분을 설정하지 않으면 기본적으로 URL Safe Base64 사용
.signWith(jwtSecretKey, SignatureAlgorithm.HS512)
.compact();

그리고 파싱할 때도 역시 Decoder를 설정해 준다.

Claims body = Jwts.parser().setSigningKey(jwtSecretKey).base64UrlDecodeWith(Decoders.BASE64).parseClaimsJws(token).getBody();


'IT > 개발' 카테고리의 다른 글

local Docker 개발 환경 만들기  (0) 2018.10.19
Spring @Transactional 사용하기  (0) 2018.10.18
객체지향 이란  (0) 2018.06.10
레거시에 Solr 적용하기  (0) 2018.05.22
Redis HA  (0) 2018.04.05
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함