JWT 기술을 활용하면 손쉽게 클라이언트(앱) 기반 프로그램에서 토큰 기반 인증 및 REST API 호출을 구현할 수 있다.
클라이언트와 서버간의 인증 시나리오를 정리하면 아래와 같다.
그 후, ❸ 사용자가 서버에 요청을 할 때 마다 JWT를 포함하여 전달한다. ❹ 서버는 클라이언트에서 요청을 받을때 마다, 해당 토큰이 유효하고 인증됐는지 검증을 하고, 사용자가 요청한 작업에 권한이 있는지 확인하여 작업을 처리.
Spring 기반의 서버 사이드 JWT
<!-- JWT -->
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
package architecture.community.security.spring.authentication.jwt;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
public class JwtTokenProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
/**
* JWT Token expire time.
*/
static final long EXPIRATIONTIME = 864_000_000; // 10 days
/**
* Secret Key string
*/
static final String SECRET = "ThisIsASecret";
/**
* JWT Token prefix
*/
static final String TOKEN_PREFIX = "Bearer";
/**
* Header key for JWT Token
*/
static final String HEADER_STRING = "Authorization";
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream().map(authority -> authority.getAuthority())
.collect(Collectors.joining(","));
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime expirationDateTime = now.plus(EXPIRATIONTIME, ChronoUnit.MILLIS);
Date issueDate = Date.from(now.toInstant());
Date expirationDate = Date.from(expirationDateTime.toInstant());
return Jwts.builder().setSubject(authentication.getName()).claim(AUTHORITIES_KEY, authorities)
.signWith(SignatureAlgorithm.HS512, SECRET).setIssuedAt(issueDate).setExpiration(expirationDate)
.compact();
}
public Authentication getAuthentication(String token, UserDetailsService userDetailsService, boolean refresh) {
Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
Collection authorities = Arrays
.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
UserDetails details = userDetailsService.loadUserByUsername(claims.getSubject());
return new UsernamePasswordAuthenticationToken(details, "", refresh ? details.getAuthorities() : authorities);
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
Collection authorities = Arrays
.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(SECRET).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.info("Invalid JWT signature: " + e.getMessage());
logger.debug("Exception " + e.getMessage(), e);
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT claims string is empty: {}", e.getMessage());
}
return false;
}
}
package architecture.community.security.spring.authentication.jwt;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;
import org.springframework.web.filter.GenericFilterBean;
import architecture.ee.util.StringUtils;
import io.jsonwebtoken.ExpiredJwtException;
public class JWTFilter extends GenericFilterBean {
private Logger logger = LoggerFactory.getLogger(getClass());
private final JwtTokenProvider jwtTokenProvider;
@Autowired(required = false)
@Qualifier("userDetailsService")
private UserDetailsService userDetailsService;
public JWTFilter(JwtTokenProvider jwtTokenProvider) {
Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void afterPropertiesSet() {
Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
}
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String header = request.getHeader(JwtTokenProvider.HEADER_STRING);
if (StringUtils.isNullOrEmpty(header)) {
chain.doFilter(request, response);
return;
}
try {
String jwt = this.resolveToken(request);
if (!StringUtils.isNullOrEmpty(jwt)) {
logger.debug("jwt token : {}", jwt);
if (this.jwtTokenProvider.validateToken(jwt)) {
Authentication authentication;
if (userDetailsService != null) {
authentication = this.jwtTokenProvider.getAuthentication(jwt, userDetailsService, true);
} else {
authentication = this.jwtTokenProvider.getAuthentication(jwt);
}
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (ExpiredJwtException eje) {
logger.info("Security exception for user {} - {}", eje.getClaims().getSubject(), eje.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
logger.debug("Exception " + eje.getMessage(), eje);
return;
}
chain.doFilter(request, response);
this.resetAuthenticationAfterRequest();
}
private void resetAuthenticationAfterRequest() {
logger.debug("reset authentication as null.");
SecurityContextHolder.getContext().setAuthentication(null);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(JwtTokenProvider.HEADER_STRING);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtTokenProvider.TOKEN_PREFIX)) {
String jwt = bearerToken.substring(7, bearerToken.length());
return jwt;
}
return null;
}
}
<http auto-config="true" use-expressions="true" disable-url-rewriting="true">
<cors configuration-source-ref="corsSource"/>
<headers>
<frame-options policy="SAMEORIGIN" />
</headers>
<intercept-url pattern="/*" access="permitAll"/>
<intercept-url pattern="/error/*" access="permitAll"/>
<intercept-url pattern="/data/**" access="permitAll"/>
<intercept-url pattern="/display/**" access="permitAll"/>
<intercept-url pattern="/accounts/**" access="permitAll"/>
<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />
<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" />
<!-- Form Login Page Setting -->
<form-login login-page="/accounts/login"
username-parameter="username"
password-parameter="password"
login-processing-url="/accounts/auth/login_check"
authentication-success-handler-ref="authenticationSuccessHandler"
authentication-failure-handler-ref="authenticationFailureHandler" />
<http-basic />
<custom-filter before="BASIC_AUTH_FILTER" ref="jwtFilter" />
...
</http>
<beans:bean id="jwtTokenProvider" class="architecture.community.security.spring.authentication.jwt.JwtTokenProvider"></beans:bean>
<beans:bean id="jwtFilter" class="architecture.community.security.spring.authentication.jwt.JWTFilter">
<beans:constructor-arg ref="jwtTokenProvider" />
</beans:bean>
주의할 것은 보안상 이유에서 웹 브라우져는 스크립트에서 웹페이지가 로드된 원 도메인(IP)이 아닌 타 도메인(IP) 자원을 호출하는 것을 금지하고 있다.
<http auto-config="true" use-expressions="true" disable-url-rewriting="true">
<cors configuration-source-ref="corsSource"/>
<headers>
<frame-options policy="SAMEORIGIN" />
</headers>
<intercept-url pattern="/*" access="permitAll"/>
<intercept-url pattern="/error/*" access="permitAll"/>
<intercept-url pattern="/data/**" access="permitAll"/>
<intercept-url pattern="/display/**" access="permitAll"/>
<intercept-url pattern="/accounts/**" access="permitAll"/>
<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />
<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" />
<!-- Form Login Page Setting -->
<form-login login-page="/accounts/login"
username-parameter="username"
password-parameter="password"
login-processing-url="/accounts/auth/login_check"
authentication-success-handler-ref="authenticationSuccessHandler"
authentication-failure-handler-ref="authenticationFailureHandler" />
<http-basic />
...
</http>
<beans:bean id="corsSource" class="org.springframework.web.cors.UrlBasedCorsConfigurationSource">
<beans:property name="corsConfigurations">
<util:map>
<beans:entry key="/**">
<beans:bean class="org.springframework.web.cors.CorsConfiguration">
<beans:property name="allowCredentials" value="true"/>
<beans:property name="allowedHeaders">
<beans:list>
<beans:value>Authorization</beans:value>
<beans:value>Content-Type</beans:value>
<beans:value>responseType</beans:value>
<beans:value>encoding</beans:value>
</beans:list>
</beans:property>
<beans:property name="allowedMethods">
<beans:list>
<beans:value>POST</beans:value>
<beans:value>GET</beans:value>
<beans:value>PUT</beans:value>
<beans:value>DELETE</beans:value>
<beans:value>OPTIONS</beans:value>
</beans:list>
</beans:property>
<beans:property name="allowedOrigins" value="*" />
<beans:property name="exposedHeaders">
<beans:list>
<beans:value>Location</beans:value>
<beans:value>Content-Disposition</beans:value>
</beans:list>
</beans:property>
<beans:property name="maxAge" value="86400" /></beans:bean>
</beans:entry>
</util:map>
</beans:property>
</beans:bean>
JWT 기반 인증 및 검증
아이디/비밀번호 기반의 인증에서 JWT 토튼을 사용하려면 인증이 성공하면 클라이언트에 토큰 값을 응답해주어야 한다. 아래는 스프링 컨트롤러에 간단하게 로그인 인증후 Jwt 토큰 값을 리턴하는 예이다.@RequestMapping(value = "/signin.json", method = { RequestMethod.POST})
public ResponseEntity<JwtResponse> authenticateUser(@RequestBody LoginRequest loginRequest) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtTokenProvider.createToken(authentication);
CommuintyUserDetails details = SecurityHelper.getUserDetails(authentication);
return ResponseEntity.ok(
new JwtResponse(
jwt,
details.getUser(),
getRoles(details.getAuthorities())));
}
var renderTo = $('form[name=form-signin]');
var observable = new kendo.observable({
username : null,
password : null
});
var validator = renderTo.kendoValidator({
errorTemplate: " #=message#
"
}).data("kendoValidator");;
kendo.bind( renderTo , observable );
renderTo.submit(function(e) {
e.preventDefault();
if( validator.validate() ){
kendo.ui.progress(renderTo, true);
axios.post(studio.services.getApiUrl('/data/accounts/signin.json'),
JSON.stringify(observable),
{ headers: { "Content-Type": "application/json" }}
).then(response => {
const data = response.data;
studio.services.accounts.loginSuccess(studio.services.accounts.state, data);
window.location.replace("index.html");
}).catch(function (error) {
// handle error
studio.ui.handleAxiosError(error);
observable.set('password', null);
})
.then(function () {
// always executed
kendo.ui.progress(renderTo, false);
});
}
});
index.js
const user = JSON.parse(localStorage.getItem("user"));
const initialState = user
? { status: { loggedIn: true }, user }
: { status: { loggedIn: false }, user: null };
function authHeader() {
// return authorization header with jwt token
let _user = JSON.parse(localStorage.getItem("user"));
if (_user && _user.jwtToken) {
return { Authorization: "Bearer " + _user.jwtToken };
} else {
return {};
}
}
function loginSuccess(state, data) {
if (data.jwtToken) {
// store user details and jwt token in local storage to keep user logged in between page refreshe
localStorage.setItem("user", JSON.stringify(data));
}
state.loggedIn = true;
state.user = data;
}
서버 호출이 필요한 경우 아래와 같은 방식으로 헤더에 jwt 포함하여 전송한다.
const headers = {
Accept: "application/json",
"Content-Type": "application/json"
};
Object.assign(headers, studio.services.accounts.authHeader());
...
axios({
url: studio.services.getApiUrl('/data/secure/mgmt/locale/save-or-update.json'),
method: "post",
data: JSON.stringify({ locale: $this.locale, timeZone: $this.timezone }),
headers: headers
}).then(response => {
let data = response.data;
dialog.close();
}).catch(function (error) {
studio.ui.handleAxiosError(error);
}).then(function () {
// always executed
kendo.ui.progress($('.k-dialog'), false);
});
JWT 기반 인증 적용 후 경험한 문제들
- spring security 에서 중복로그인 방지를 설정한 경우 중복 로그인 오류로 인하여 정상적인 응답을 받지 못하는 경우가 다수 발생했다.
- 인증된 사용자에게만 보여지는 이미지와 같은 바이너리 형식을 처리하는 경우에 어려움이 있었다.
참고자료
Securing Spring Boot with JWTsJWT (JSON Web Token) 이해와 활용
CORS support in Spring Framework


댓글 없음:
댓글 쓰기