Spring Security는 강력한 사용자 인증 및 Access 제어 framework이다. 이는 Java 애플리케이션에 인증 및 권한 부여를 제공하는데 중점을 두었으며 다양한 필터를 사용하여 커스터마이징이 가능하다.
'인증(Authentication)은 주체(Principal)의 신원(Identity)를 증명하는 과정입니다.'라는 주장을 검증하는 과정이다.
여기서 주체는 보통 유저(사용자)를 가리키며 주체는 자신을 인증해달라고 신원 증명 정보, 즉 Credential을 제시한다. 주체가 유저일 경우 크레덴셜은 대개 패스워드이다.
인가(Authorization, 권한 부여)는 인증을 마친 유저에게 권한 Authority을 부여하여 대상 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정이다. 인가는 반드시 인증 과정 이후 수행돼야하며 권한은 롤 형태로 부여하는 것이 일반적이다.
접근통제(Access Control, 접근 제어)는 애플리케이션 리소스에 접근하는 행위를 제어하는 일이다. 따라서 어떤 유저가 어떤 리소스에 접근하도록 허락할지를 결정하는 행위, 즉 접근 통제 결정이 뒤따른다. 리소스의 접근 속성과 유저에게 부여된 권한 또는 다른 속성들을 견주어 결정한다.
(출처 : Spring 5 레시피)
Spring Security는 표준 서블릿 필터를 사용한다. 다른 요청들과 마찬가지로 HttpServletRequest와 HttpServletResponse를 사용한다. Spring Security는 서비스 설정에 따라 필터를 내부적으로 구성한다. 각 필터는 각자 역할이 있고 필터 사이의 종속성이 있으므로 순서가 중요하다. XML Tag를 이용한 네임스페이스 구성을 사용하는 경우 필터가 자동으로 구성되지만, 네임스페이스 구성이 지원하지않는 기능을 써야하거나 커스터마이징된 필터를 사용해야 할 경우 명시적으로 빈을 등록 할 수 있다.
클라이언트가 요청을 하면 DelegatingFilterProxy가 요청을 가로채고 Spring Security의 빈으로 전달한다. (tmi. DispatcherServlet보다 먼저 실행 됨) 이 DeletgatingFilterProxy는 web.xml과 applicationContext 사이의 링크를 제공한다. 그러니까 DelegatingFilterProxy는 한 마디로 Spring의 애플리케이션 컨텍스트에서 얻은 Filter Bean을 대신 실행한다.
그러니 이 Bean은 javax.servlet.Filter를 구현해야한다. 이 포스팅에서는 jwtAuthenticationFilter가 되겠다. (밑에 소스 코드 참조)
Spring Security Filter Chain는 아래와 같이 다양하며 커스마이징이 가능하다.
구글링을 해봤는데 이게 가장 알기쉽게 정리 되어있어서 가져왔다. 이 그림의 프로세스를 하나씩 짚어보자면
Authentication정보는 결국 SecurityContextHolder 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다. 세션에 사용자 정보를 저장한다는 것은 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.
JWT(Json Web Token)은 웹표준(RFC7519)로서 일반적으로 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다.
HEADER.PAYLOAD.SIGNATURE
일반적으로 헤더, 페이로드, 서명 세 부분을 점으로 구분하는 구조로 되어있다.
헤더에는 토큰 타입과 해싱 알고리즘을 저장하고
페이로드에는 실제로 전달하는 정보, 서명에는 위변조를 방지하기위한 값이 들어가게된다.
사용자 인증이 완료될 시 서버측에서는 JWT 토큰을 Body에 담아오게되고 그 후 요청하는 API 서버에 JWT 토큰을 헤더에 담아 요청을 하게 되면 이를 확인하고 권한이 있는 사용자에게 리소스를 제공하게 된다.
Intellij IDEA 2019.3.3
SpringFramework 2.3.1.RELEASE
gradle
JDK 1.8
dependencies {
.
.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
.
.
.
}
일단 필요한 라이브러리들을 받아주고, domain 객체부터 작성한다.
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name="USERS")
public class User{
@Id
@Column(name = "user_id")
private String userId;
@Column(name = "user_name")
private String userName;
@Column(name = "user_pwd")
private String userPwd;
@Column
private String company;
@Column
private String position;
}
그 후, DB 접근 시 사용할 Repository와 Service를 만들어줬다. (Service의 경우 나중에 만들어도 됨)
@Repository
public interface UserRepository extends JpaRepository<User, String> {
}
UserService의 경우 인증용 도메인과 일반 API 용을 따로 만든다고 하던데.. 난 그냥 쓰던거 사용하고 싶어서 함수 하나만 추가해줬다.
@Service
public class UserService {
@Autowired
UserRepository userRepository;
.
.
.
public Optional<User> findByIdPw(String id) { return userRepository.findById(id); }
.
.
}
그 후 Spring Security Filter Chain을 사용한다는 것을 명시해줘야 한다. WebSecurityConfigurerAdapter 상속받은 Configuration 객체를 일단 만들어주고 거기에 @EnableWebSecurity 어노테이션을 달아주면 된다.
@Slf4j
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private final JwtAuthenticationEntryPoint unauthorizedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors() //(1)
.and()
.csrf() //(2)
.disable()
.exceptionHandling() //(3)
.authenticationEntryPoint(unauthorizedHandler)
.and()
.sessionManagement() //(4)
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests() // (5)
.antMatchers(RestControllerBase.API_URI_PREFIX + "/auth/**")
.permitAll()
.antMatchers(RestControllerBase.API_URI_PREFIX + "/**")
.authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.formLogin().disable().headers().frameOptions().disable();
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("*");
configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
configuration.addAllowedHeader("*");
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
//비밀번호 암호화를 위한 Encoder 설정
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
(1) 교차출처 리소스 공유(CORS) 설정이다. 나는 현재 vue.js를 클라이언트로 사용하고 있어 서로 다른 포트를 사용하고 있다.(8089, 8081) 그래서 corsConfigurationSource Bean을 통해 addAllowedOrigin (Access-Control-Allow-Origin)에 모든 출처를 허용, setAllowedMethods에 위와 같은 Request 방식을 허용, addAllowedHeader (Access-Control-Allow-Methods)를 허용하도록 설정해줬다.
(2) CSRF(Cross Site Request Forgery) 사이트 간 요청 위조 설정이다. 이건 설정이 복잡하기도하고 REST API 서버용으로만 사용할거기 때문에 disable 해줬다.
(3) 인증, 허가 에러 시 공통적으로 처리해주는 부분이다.
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e) throws IOException {
log.error("Responding with unauthorized error. Message - {}", e.getMessage());
ErrorCode unAuthorizationCode = (ErrorCode) request.getAttribute("unauthorization.code");
request.setAttribute("response.failure.code", unAuthorizationCode.name());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, unAuthorizationCode.message());
}
}
(4) JWT를 쓰려면 Spring Security에서 기본적으로 지원하는 Session 설정을 해제해야 한다.
(5) 요청에 따른 권한 체크 설정 부분이다. /auth/** 경로로 들어오는 부분은 인증이 필요없고, 그 외 모든 요청들은 인증을 거치도록 설정해줬다.
++ 참고로 Spring Security에서 기본적으로 제공하는 로그인 화면을 사용할거라면 http.httpBasic() 설정을 해줘야한다. 그러면 실행 시 화면이 이렇게 나옴.
이 다음은 JWT 토큰을 통한 인증을 설정해줄 차례이다.
@Slf4j
public class JwtTokenProvider {
private static final String JWT_SECRET = "secretKey";
// 토큰 유효시간
private static final int JWT_EXPIRATION_MS = 604800000;
// jwt 토큰 생성
public static String generateToken(Authentication authentication) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);
return Jwts.builder()
.setSubject((String) authentication.getPrincipal()) // 사용자
.setIssuedAt(new Date()) // 현재 시간 기반으로 생성
.setExpiration(expiryDate) // 만료 시간 세팅
.signWith(SignatureAlgorithm.HS512, JWT_SECRET) // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅
.compact();
}
// Jwt 토큰에서 아이디 추출
public static String getUserIdFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(JWT_SECRET)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
// Jwt 토큰 유효성 검사
public static boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token);
return true;
} catch (SignatureException ex) {
log.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
log.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
log.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
log.error("JWT claims string is empty.");
}
return false;
}
}
이제 Jwt 인증 Filter를 만들어준다.
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request); //request에서 jwt 토큰을 꺼낸다.
if (StringUtils.isNotEmpty(jwt) && JwtTokenProvider.validateToken(jwt)) {
String userId = JwtTokenProvider.getUserIdFromJWT(jwt); //jwt에서 사용자 id를 꺼낸다.
UserAuthentication authentication = new UserAuthentication(userId, null, null); //id를 인증한다.
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); //기본적으로 제공한 details 세팅
SecurityContextHolder.getContext().setAuthentication(authentication); //세션에서 계속 사용하기 위해 securityContext에 Authentication 등록
} else {
if (StringUtils.isEmpty(jwt)) {
request.setAttribute("unauthorization", "401 인증키 없음.");
}
if (JwtTokenProvider.validateToken(jwt)) {
request.setAttribute("unauthorization", "401-001 인증키 만료.");
}
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.isNotEmpty(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring("Bearer ".length());
}
return null;
}
}
public class UserAuthentication extends UsernamePasswordAuthenticationToken {
public UserAuthentication(String principal, String credentials) {
super(principal, credentials);
}
public UserAuthentication(String principal, String credentials,
List<GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
}
이제 이렇게 만든 Filter를 사용할 수 있도록 SecurityConfiguration에 등록해준다.
.
.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf()
.disable()
.exceptionHandling()
.authenticationEntryPoint(unauthorizedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers(RestControllerBase.API_URI_PREFIX + "/auth/**")
.permitAll()
.antMatchers(RestControllerBase.API_URI_PREFIX + "/**")
.authenticated();
}
.
.
이제 막바지다. 로그인 시 인증을 하도록 만들어주면 된다. 일단 입력한 아이디와 비밀번호를 담는 Request 도메인, jwt 토큰 값을 받는 Response 도메인을 만들어준다.
public class Token {
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class Request {
private String id;
private String secret;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static final class Response {
private String token;
}
}
그리고 Controller 작업.
@Slf4j
@RestController
@RequestMapping(AuthRestController.URL_PREFIX)
public class AuthRestController extends RestControllerBase {
static final String URL_PREFIX = API_URI_PREFIX + "/auth";
static final String LOGIN = "/login";
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserService userService;
@RequestMapping(
value = LOGIN,
method = RequestMethod.POST,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<?> login(
final HttpServletRequest req,
final HttpServletResponse res,
@Valid @RequestBody Token.Request request) throws Exception {
User user = userService.findByIdPw(request.getId()).orElseThrow(() -> new IllegalArgumentException("없는 사용자입니다."));
if(!request.getSecret().equals(user.getUserPwd())){
throw new IllegalArgumentException("비밀번호를 확인하세요.");
}
Authentication authentication = new UserAuthentication(request.getId(), null, null);
String token = JwtTokenProvider.generateToken(authentication);
Response response = Response.builder().token(token).build();
return okResponse(response);
}
}
API_URI_PREFIX 같은 건 path에 대한 설정이니 무시하고 본인이 원한대로 진행하면 된다. 여기서 볼 건 DB에 저장된 사용자 인증값과 request를 통해 넘어온 사용자 인증 값을 비교해주고 같다면 인증을 진행하는 부분이다.
이제 Postman같은 API 테스트 플랫폼에서 테스트하면 결과값을 확인할 수 있다.