안녕하세요. 오늘은 Spring에서 Springs Security 적용 방법과 자동 로그인 구현에 대하여 작성해보겠습니다.
1. Spring Security란
Spring Security는 스프링 프레임워크의 보안 모듈로, 애플리케이션의 Authentication(인증)과 Authorization(인가)를 담당하는 강력한 보안 기능을 제공합니다. 주로 웹 애플리케이션과 API에서 사용되며, 다음과 같은 주요 기능을 제공합니다.
1) Authentication(인증)
인증은 사용자가 누구인지 확인하는 과정입니다.
Spring Security는 다양한 인증 방식을 지원하며, 기본적으로 폼 로그인, HTTP 기본 인증, OAuth, JWT 등을 사용할 수 있습니다.
인증 과정에서 사용자의 아이디와 비밀번호를 확인한 후, 해당 사용자가 유효한지 확인하고, 성공적으로 인증된 사용자는 SecurityContext에 저장됩니다.
2) Authorization(인가)
인가는 인증된 사용자가 애플리케이션 내에서 특정 리소스나 기능에 접근할 수 있는 권한을 부여하는 과정입니다.
Spring Security는 역할 기반(Role-based) 인가를 지원하며, 사용자가 특정 요청을 할 때 해당 요청이 허가된 요청인지 확인합니다.
예를 들어, 관리자만 접근할 수 있는 페이지를 설정하거나, 특정 API를 특정 권한을 가진 사용자만 호출할 수 있도록 제한할 수 있습니다.
3) 보안설정
Spring Security는 보안 설정을 간단하게 구성할 수 있도록 다양한 설정 방법을 제공합니다.
XML 설정 또는 자바 기반의 애너테이션 기반 설정을 사용할 수 있습니다.
예를 들어, 특정 경로에 대한 접근 권한을 설정하거나, 로그인 페이지와 로그아웃 처리 등을 쉽게 설정할 수 있습니다.
4) 암호화
Spring Security는 비밀번호를 안전하게 저장하고 비교할 수 있도록 암호화(해싱) 기능을 제공합니다.
보통 BCryptPasswordEncoder와 같은 해싱 알고리즘을 사용하여 비밀번호를 안전하게 저장할 수 있습니다.
5) 확장 가능성
Spring Security는 확장성이 뛰어나며, 기본 제공 기능 외에도 커스텀 필터, 인증 방식 등을 쉽게 추가할 수 있습니다.
다양한 보안 요구사항을 충족할 수 있도록 세밀하게 제어할 수 있습니다.
요약해서 말씀드리자면 Spring Security는 애플리케이션을 다양한 인증과 인가 방식, 보안 설정, 암호화 기능등을 제공하여 웹 애플리케이션과 API의 보안을 강화합니다.
아래는 Spring Security의 Authentication Architecture입니다.
Spring Security는 아래 이미지와 같은 Architecture를 가지며 보안 처리를 하고 있는 모습을 볼 수 있습니다.
2. Security structure
우리는 위에서 Spring Security Architecture 이미지를 보았습니다. 이에 대하여 저의 Secret Diary Back-end 프로젝트에 적용하고자 아래와 같은 구조도를 만들고 Spring Security를 구현하였습니다.
이는 Architecture에 대한 security의 설계도로 위와 같은 흐름으로 저의 프로젝트에 적용하였습니다.
이에 대하여 Spring Security를 구성하는 각 파일에 대한 설명을 작성해보겠습니다.
1) SecurityConfig.java
SecurityConfig 파일은 Spring Security의 보안 설정을 정의하는 클래스입니다. 애플리케이션의 보안 정책을 수립하고 관리하기 위해 필요한 설정들을 담고 있으며, 주로 인증(Authentication)과 인가(Authorization)에 대한 설정을 수행합니다. 이 파일을 통해 애플리케이션의 사용자 인증 방식, 권한 제어, 로그인/로그아웃 처리, 세션 관리, CSRF 보호 등 다양한 보안 기능을 커스터마이징할 수 있습니다.
아래는 SecurityConfig.java 코드입니다.
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
//SecurityConfig는 Spring Security의 설정을 정의하는 곳으로 애플리케이션의 요청 경로에 대한 인증 및 권한 설정, JWT 필터 적용, 암호화 방식등을 설정함
private final UserDetailService userService;
private final JwtUtil jwtUtil;
private static final String[] permit_userController_list = {
"security/join","security/update/{userEmail}","security/login",
"/home/{userId}",
"security/user/{userEmail}","/user/image/{filename}",
"search/{keyword}/{userEmail}","delete/{userEmail}"
};
private static final String[] permit_noticeController_list = {
"upload","findAll2","read/notice/user","read/detail/notice",
"search/notice","/notice/image/{filename}"
};
private static final String[] permit_friendController_list = {
"friend/request/{userEmail}/{friendEmail}","friend/request/list/{userEmail}",
"friend/accept/{userEmail}/{friendEmail}","friend/my/{userEmail}",
"friend/my/search/{userEmail}/{friendEmail}",
"friend/check/{userEmail}/{friendEmail}",
"friend/request/check/{userEmail}/{friendEmail}"
};
String[] allPermitList = Stream.concat(
Stream.concat(Stream.of(permit_userController_list), Stream.of(permit_noticeController_list)),
Stream.of(permit_friendController_list)
).toArray(String[]::new);
@Bean
public WebSecurityCustomizer configure(){
return (web -> web.ignoring()
//spring security가 지정된 경로를 무시하도록 설정
//.requestMatchers("home"));
.requestMatchers("static/**")); //static/**의 경우 정적 리소스 폴더(예: CSS, JS 파일등)로 들어오는 모든 요청 무시
}
//SecurityFilterChain의 requestMatcheres의 경우 특정 경로에 대해 인증 및 권한 검사 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//csrf 보안 비활성화
http.csrf((csrf) -> csrf.disable());
//FormLogin, BasicHttp 비활성화
http.formLogin((form) -> form.disable());
http.httpBasic(AbstractHttpConfigurer :: disable);
//JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
http.addFilterBefore(new JwtAuthFilter(userService, jwtUtil), UsernamePasswordAuthenticationFilter.class);
//권한 규칙 작성
http.authorizeHttpRequests(auth -> auth
.requestMatchers(
allPermitList
).permitAll() //권한이 필요하지 않은 경로
.requestMatchers(
"/security/autoLogin", "/security/logout"
).authenticated()
.anyRequest().authenticated() //권한이 필요한 경로
);
//로그아웃
http.logout(logout -> logout
.logoutUrl("security/logout")
.logoutSuccessUrl("/security/login") //로그아웃 성공시 이동 할 페이지
.invalidateHttpSession(true) //세션 무효화
.permitAll() //로그아웃 url 접근 허용
);
return http.build();
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userService);
daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
return daoAuthenticationProvider;
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
2) JwtUtil.java
이 코드는 Spring Boot 기반 애플리케이션에서 JWT(Json Web Token)를 생성하고 검증하는 유틸리티 클래스 JwtUtil입니다. JWT는 사용자 인증에 많이 사용되는 토큰 기반 인증 방식으로, 이 클래스는 JWT 토큰을 생성, 검증하고 토큰에 담긴 정보를 추출하는 데 사용됩니다.
JWT에 대해 더 자세한 정보는 제가 이전에 작성한 JWT의 개념과 이해라는 게시물을 참조 부탁드립니다. 아래에 링크 달아드리겠습니다.
https://pinlib.tistory.com/entry/jwt
아래는 JwtUtil.java 코드입니다.
@Slf4j
@Component
public class JwtUtil {
private final Key key;
private final long accessTokenExpTime;
private final Logger logger = LoggerFactory.getLogger(this.getClass());
public JwtUtil(
@Value("${jwt.secret}")
String secretKey,
@Value("${jwt.expiration_time}")
long accessTokenExpTime
) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpTime = accessTokenExpTime;
}
//call, userDTO와 accessTokenExpTime(만료시간)울 토대로 JWT생성
public String createAccessToken(UserDTO userDTO) {
return createToken(userDTO, accessTokenExpTime);
}
//실질적인 JWT토큰 생성 메소드
private String createToken(UserDTO userDTO, long expireTime) {
Claims claims = Jwts.claims(); //Claims 객체 생성
//claims 객체에 사용자 정보 저장
claims.put("memberId", userDTO.getId());
claims.put("email", userDTO.getEmail());
//JWT 발행시간 및 만료시간 설정
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(expireTime);
logger.info("JwtUtil class pass");
//사용자 정보와 만료시각등을 설정하고 HMAC-SHA256 algorithm을 사용하여 비밀키로 서명 추가
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
//주어진 JWT에서 memberId정보를 추출하는 메소드로 parseClaims를 이용하여 토큰의 정보를 분석한 후 memberId 추출
public Long getUserId(String token) {
return parseClaims(token).get("memberId", Long.class);
}
public String getUserEmail(String token){
return parseClaims(token).get("email", String.class);
}
//JWT 토큰이 유효한지 검증으로 토큰을 파싱할 때 발생할 수 있는 다양한 예외를 처리하여 토큰이 유효한지 여부를 확인
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { //토큰이 변조되었거나 형식이 잘못된 경우
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) { //토큰이 만료된 경우
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) { //지원하지 않는 토큰 형식인 경우
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) { //토큰의 내용이 비어있는 경우
log.info("JWT claims string is empty.", e);
}
return false;
}
//주어진 JWT 토큰을 파싱하여 그 안에 포함된 claims(토큰에 담긴 정보)를 반환하는 메소드
//만약 토큰이 만료되었더라도 만료된 claims를 반환하여 토큰에 담긴 정보를 사용할 수 있음
public Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
해당 코드에서
public JwtUtil(
@Value("${jwt.secret}")
String secretKey,
@Value("${jwt.expiration_time}")
long accessTokenExpTime
)
이 부분의 경우
spring project의 application.properties에 작성했던
jwt.expiration_time=86400000
jwt.secret=VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
jwt에 대한 토큰 만료시간과 비밀 키등을 참고하는 코드입니다.
3) UserDetailService.java
Spring Security에서 사용자 인증을 위해 사용자 정보를 로드하는 역할을 합니다. 이 클래스는 UserDetailsService인터페이스를 구현하고 있으며, 주로 JWT 인증 과정에서 사용자 정보를 데이터베이스로부터 가져오는 역할을 합니다.
아래는 UserDetailService.java 코드입니다.
@RequiredArgsConstructor
@Service
public class UserDetailService implements UserDetailsService {
private final UserRepository userRepository;
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public User loadUserByUsername(String email){
logger.info("UserDetailService pass");
logger.info("userEmail :: {}", email);
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException(email));
}
}
4) JwtAuthFilter.java
이 코드는 JWT 인증 필터로, Spring Security의 인증 과정에서 JWT 토큰을 검증하고, 검증이 성공하면 해당 토큰을 바탕으로 사용자 인증 정보를 설정하는 역할을 합니다. 이를 통해 Spring Security의 보안 컨텍스트에 사용자 정보를 추가하고, 요청을 처리하는 다음 필터나 컨트롤러로 넘깁니다. 또한 이후에는 해당 유저가 인증된 사용자로 처리되도록 합니다.
아래는 JwtAuthFilter.,java 코드입니다.
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter { // OncePerRequestFilter -> 한 번 실행 보장
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//클라이언트가 보낸 HTTP 요청의 Authorization 헤더값을 가져옴
String authorizationHeader = request.getHeader("Authorization");
//JWT가 헤더에 있는 경우
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { //요청에서 Authorization 헤더를 읽어 Bearer로 시작하는 토큰 확인
String token = authorizationHeader.substring(7);
//JWT 유효성 검증
if (jwtUtil.validateToken(token)) {
//Long userId = jwtUtil.getUserId(token);
String userEmail = jwtUtil.getUserEmail(token);
//유저와 토큰 일치 시 userDetails 생성
//토큰이 유효하다면 UserDetailsService를 통해 사용자 정보 load
//UserDetails userDetails = userDetailsService.loadUserByUsername(userId.toString());
UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail.toString());
logger.info("JwtAuthFilter first complete");
if (userDetails != null) {
//UserDetsils, Password, Role -> 접근권한 인증 Token 생성
//인증된 사용자 정보와 권한을 포함한 UsernamePasswordAuthenticationToken을 생성하여 Spring Security의 SecurityContextHolder에 설정
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//현재 Request의 Security Context에 접근권한 설정
//이로인해 이후의 요청 처리가 인증된 사용자로 진행
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
logger.info("JwtAuthFilter second complete");
} else {
logger.info("Invalid token");
}
}
}
logger.info("JwtAuthFilter third complete");
filterChain.doFilter(request, response); // 다음 필터로 넘기기
}
}
이렇게 Spring Security를 구성하고 있는 파일들의 역할과 코드들에 대해 알아보았습니다.
이제부터는 Spring Seucrity가 작동하는 흐름에 대해 알아보겠습니다.
3. Spring Security Flow(로그인, 자동 로그인, 로그아웃)
이번 게시물에서 Spring Security가 작동하는 흐름의 예시로 Login과 AutoLogin Logout의 작동을 예시로 보여드리겠습니다.
1) Login
첫 번째로, client에서 spring에게 security/login 경로로 로그인 요청을 보냅니다.
해당 요청을 받은 spring은 SecurityConfig에서 등록되어 있는 경로인지 확인하고, 등록되어있는 경로라면 인증이 필요한 경로에 속하는 지 혹은, 인증이 필요없는 경로에 속하는지 확인합니다.
제 코드의 경우 permitAll()에 포함되어 있는 인증이 필요없는 경로에 속합니다.
로그인의 경우에는 아직 JWT가 클라이언트에게 발급되지 않은 상황이므로 로그인을 원하는 사용자는 인증을 받을 수 없기 떄문에 인증받은 경로로에 로그인이 속해서는 안됩니다.
인증이 필요없는 경로임을 SecurityConfig에서 확인했으므로 controller로 이동합니다.
@PostMapping("security/login")
public ResponseEntity<String> login(@Validated @RequestBody LoginRequestDTO loginRequestDTO){
String token = this.userService.login(loginRequestDTO);
return ResponseEntity.status(HttpStatus.OK).body(token);
}
이제는 service로 이동합니다.
public String login(LoginRequestDTO dto){
String email = dto.getEmail();
String password = dto.getPassword();
User user = userRepository.findUserByEmail(email);
if(user == null) {
throw new UsernameNotFoundException("이메일이 존재하지 않습니다");
}
// 암호화된 password를 디코딩한 값과 입력한 패스워드 값이 다르면 null 반환
if(!encoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
//LoginRequestDTO를 기반으로 찾은 user객체에 담긴 정보를 UserDTO에 저장함 (entity를 dto로 변환했다고 보면 됨)
UserDTO info = modelMapper.map(user, UserDTO.class);
//UserDTO에 담긴 정보를 기반으로 jwtUtil에 있는 createAccessToken을 call하여 token을 생성하고 반환
String accessToken = jwtUtil.createAccessToken(info);
return accessToken;
}
여기서 위에 보여드렸던 구조도 이미지에는 나왔지만 지금까지 언급이 없던 modelMapper가 나옵니다.
modelMapper의 경우 User entity에 담겨있는 정보를 UserDTO에 저장하는 method입니다.
아래는 modelMapper의 코드입니다.
@Configuration
public class ModelMapperConfig {
@Bean
public ModelMapper modelMapper(){
return new ModelMapper();
}
}
이렇게 userService에서는 UserDTO를 JwtUtil.java의 createAccessToken의 parameter로 하여
로그인 성공시 토큰을 발행하고 client에게 발급합니다.
2) Auto Login
첫 번째로, client에서 spring에게 security/autoLogin 경로로 로그인 요청을 보냅니다.
여기서 client는 해당 요청을 보낼때 "Authorization"을 Header에 담아 보내야 합니다.
그 이유는 Spring의 SecurityConfig에는 securtiy/autoLogin 경로가 authenticatied()로 인증이 필요한 경로로 등록되어 있습니다.
이에 대하여 client는 인증 정보를 서버에 전달하는 표준 헤더 필드인 "Authorization"를 HTTP 요청 헤더로 보내야만 하는 것입니다.
이렇게 client에서 security/autoLogin 경로로 요청을 보냈다면 Spring에서는 우선적으로 위의 경우와 마찬가지로 SecurityConfig에서 해당 경로에 대한 존재 여부를 확인 한 후 인증이 필요한 요청임을 확인하고, SecurityConfig에서 설정한 코드로 인해
http.addFilterBefore(new JwtAuthFilter(userService, jwtUtil), UsernamePasswordAuthenticationFilter.class);
JwtAuthFilter가 요청을 가로채고 작동됩니다.
JwtAuthFilter에서는
String authorizationHeader = request.getHeader("Authorization");
이 코드를 통해 client 요청에서 Authorization 헤더를 추출하고
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String token = authorizationHeader.substring(7);
if (jwtUtil.validateToken(token)) {
JWT의 유효성을 검사하게 됩니다.
여기서 Bearer이라는 text의 경우 Bearer토큰을 의미합니다.
Bearer 토큰이란 서버에서 클라이언트에게 JWT를 발급하면, 클라이언트는 이 토큰을 API 호출 시 서버에 전송합니다.
서버는 이 토큰을 검증하여 클라이언트의 권한을 확인하는데, 이때 토큰이 Bearer 토큰으로 전달됩니다.
즉, 클라이언트는 JWT를 직접적으로 제공하는 대신, Bearer 인증을 통해 이 토큰을 전달하게 됩니다.
Bearer의 경우 "Bearer "로 공백 문자열을 포함하는데, 이는 인증 방식의 이름과 실제 인증에 사용되는 JWT토큰 값을 분리하기 위한 것입니다. 클라이언트에서 Bearer로 토큰을 보내는 방식에 대해서는 다음 게시물에 작성하겠습니다.
다시 돌아와서 유효성을 검사한 후
UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail.toString());
UserDetailService의 loadUserByUsername method를 통해 토큰에서 사용자 정보를 추출하게 됩니다.
마지막으로
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
해당 코드를 통해 인증된 사용자 정보와 권한을 포함한 UsernamePasswordAuthnticationToken을 생성하여 Spring Security의 SecurityContextHolder에 설정하고 이로인해 이후의 요청 처리가 인증된 사용자로 진행되게 됩니다.
이렇게 JwtAuthFilter가 인증을 성공적으로 처리하는 본인의 역할이 끝나게 되면, 요청은 SecurityContextHolder에 설정된 인증정보와 함께 해당 경로 url과 일치하는 controller로 넘어가게 됩니다.
@PostMapping("security/autoLogin")
public ResponseEntity<Void> autoLogin(@RequestHeader("Authorization") String token){
try{
// JWT 토큰에서 "Bearer " 부분 제거
String jwtToken = token.startsWith("Bearer ") ? token.substring(7) : token;
// 토큰 검증
if (!jwtUtil.validateToken(jwtToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok().build(); // 성공 시 200 OK 반환
} catch(Exception e){
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
위 코드는 controller 코드로 여기서 다시한번 유효성 검사를 진행한 후 자동로그인을 수행하게 됩니다.
@RequestHeader의 경우 위에서 언급한바와 마찬가지로 헤더 내용을 함께 받아오기 위한 설정입니다.
3) Logout
첫 번째로, client에서 spring에게 security/logout 경로로 로그인 요청을 보냅니다.
Logout 역시 client는 해당 요청을 보낼때 "Authorization"을 Header에 담아 보내야 합니다.
또한 대부분의 과정이 autoLogin과 비슷하기 때문에 차이점을 말씀드리겠습니다.
Logout의 경우 SecurityConfig의 로그아웃을 설정하는 공간에 아래 코드와 같이 따로 설정을 하여야 합니다.
//로그아웃
http.logout(logout -> logout
.logoutUrl("security/logout")
.logoutSuccessUrl("/security/login") //로그아웃 성공시 이동 할 페이지
.invalidateHttpSession(true) //세션 무효화
.permitAll() //로그아웃 url 접근 허용
);
SecurityConfig에서 로그아웃 설정을 추가하는 이유는, 사용자가 애플리케이션에서 로그아웃할 때 발생하는 일련의 동작을 명확하게 정의하고 제어하기 위함입니다.
즉, 이 설정은 사용자가 명확하게 로그아웃 절차를 밟을 수 있게 하고, 로그아웃 후에는 이전 세션 정보를 제거하여 보안을 유지하는 데 목적이 있습니다.
이렇게 Spring에서 security 적용 방법과 자동 로그인 구현에 대해 알아보았습니다.다음 게시물은 이러한 security환경을 활용하기 위한 client에서 해야 할 일에 대해 작성하도록 하겠습니다. 감사합니다.
'Spring실습' 카테고리의 다른 글
Retrofit2를 이용한 안드로이드와 스프링 서버 통신(스프링편)(안드로이드 서버통신) (3) | 2024.10.03 |
---|---|
Spring security + jwt를 이용하여 로그인 구현하기(3) (0) | 2023.07.01 |
Spring Security + jwt를 이용하여 로그인 구현하기(2) (0) | 2023.06.26 |
Spring Security + jwt를 이용하여 로그인 구현하기(1) (0) | 2023.06.24 |
스프링부트에서 Validation사용하기(@Valid) (0) | 2023.06.02 |