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
댓글 없음:
댓글 쓰기