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>
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | 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 ; } } |
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | 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())));
}
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 31 32 33 34 | var renderTo = $( 'form[name=form-signin]' ); var observable = new kendo.observable({ username : null , password : null }); var validator = renderTo.kendoValidator({ errorTemplate: "<p class=" text-danger g-font-size-14 "><i class=" fa fa-info-circle "></i> #=message#</p>" }).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; <span style= "background-color: #b7e1cd;" >studio.services.accounts.loginSuccess(studio.services.accounts.state, data);</span> 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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 포함하여 전송한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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
댓글 없음:
댓글 쓰기