Spring Security란?
스프링 기반 애플리케이션의 인증(Authentication)과 인가(Authorization)를 담당하는 보안 프레임워크
로그인, 접근 제어, CSRF 방어, 세션 관리, OAuth2 지원 등의 다양한 보안 기능을 제공한다.
Gradle 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
}
Spring Security 설정 변경 (SecurityConfig)
Spring Boot 3.x부터는 SecurityFilterChain을 사용하여 보안 설정을 변경해야 한다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationConfiguration configuration;
// 비밀번호 암호화
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
// <script>alert("asd")</script>, 일반적으로 XSS
// <script>게시글 작성 스크립트</script>, 일반적으로 CSRF
// CSRF : 크로스 사이트 요청 변조
http.csrf(AbstractHttpConfigurer::disable);
// http 기본 인증(아이디/비밀번호 입력 창) 비활성화
http.httpBasic(AbstractHttpConfigurer::disable);
// 폼 로그인 비활성화 (기본적으로 제공되는 로그인 폼 사용 x)
http.formLogin(AbstractHttpConfigurer::disable);
// 접근 권한 설정 (인가 - Authorization)
http.authorizeHttpRequests(
(auth) -> auth
.requestMatchers("/login").permitAll() // 누구나 접근 가능
.requestMatchers("/user/signup").permitAll() // 누구나 접근 가능
.requestMatchers("/a/ex01").hasRole("USER") // "ROLE_USER"인 사용자만 접근 가능
.anyRequest().authenticated() // 그 외 요청은 로그인 필요
);
// 커스텀 로그인 필터 추가
http.addFilterAt(new LoginFilter(configuration.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class);
// JWT 필터 추가
http.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
인증(Authentication)
누구인지 확인하는 과정 (로그인)
- 사용자의 아이디와 비밀번호를 확인하고, 시스템에 접근할 수 있도록 인증하는 과정
인증 수행 과정 (UsernamePasswordAuthenticationFilter)
1. 클라이언트 요청 -> AuthenticationFilter
- 클라이언트가 HTTP 요청을 보내면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고, 그 중UsernamePasswordAuthenticationFilter(위 그림에서는AuthenticationFilter)에서 인증을 처리한다.
// MemberDto.SignupRequest MemberDto =
// new MemberDto.SignupRequest(request.getParameter("Membername"), request.getParameter("password"));
// JSON 형태의 데이터를 처리하도록 변경
MemberDto.SignupRequest MemberDto = new ObjectMapper().readValue(request.getInputStream(), MemberDto.SignupRequest.class);
2. AuthenticationFilter에서 토큰 생성
- AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성한다.
authToken = new UsernamePasswordAuthenticationToken(MemberDto.getEmail(), MemberDto.getPassword(), null);
3. 토큰 생성 후 AutheicationManager에게 토큰 전달
- AutheicationManager는 인터페이스다.
- 일반적으로 사용되는 구현체는 ProviderManager
return authenticationManager.authenticate(authToken);
4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
5. AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달한다.
6. UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.
@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {
//...
}
// UserDetailsService 인터페이스
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManger로 권한을 담은 토큰을 전달한다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByEmail(username);
if (result.isPresent()) {
Member member = result.get();
return member;
}
return null;
}
8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.
9. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
LoginFilter
Member 엔티티에서 이메일과 비밀번호를 가져와 토큰을 생성하고 인증을 처리해준다.
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// 원래는 form-data 형식으로 사용자 정보를 입력받았는데
// JSON 형태로 입력을 받기 위해서 재정의
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
System.out.println("LoginFilter 실행");
UsernamePasswordAuthenticationToken authToken;
// 그림에서 1번 로직
// MemberDto.SignupRequest MemberDto =
// new MemberDto.SignupRequest(request.getParameter("Membername"), request.getParameter("password"));
try {
// 그림에서 원래 1번이었던 로직을 JSON 형태의 데이터를 처리하도록 변경
MemberDto.SignupRequest MemberDto = new ObjectMapper().readValue(request.getInputStream(), MemberDto.SignupRequest.class);
// 그림에서 2번 로직
authToken = new UsernamePasswordAuthenticationToken(MemberDto.getEmail(), MemberDto.getPassword(), null);
} catch (IOException e) {
throw new RuntimeException(e);
}
return authenticationManager.authenticate(authToken);
}
// 로그인 성공했을 때 응답 처리
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
Member Member = (Member) authResult.getPrincipal();
String jwtToken = JwtUtil.generateToken(Member.getIdx(), Member.getEmail(), Member.getNickName(), Member.getRole());
// 빌더 패턴으로 객체를 생성
// 쿠키 생성
ResponseCookie cookie = ResponseCookie
.from("ATOKEN", jwtToken)
.path("/")
.httpOnly(true)
.secure(true)
.maxAge(Duration.ofHours(1L))
.build();
// 생성한 쿠키를 헤더에 등록
response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
}
필터들을 추가해준다.
http.addFilterAt(new LoginFilter(configuration.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class);
Sprint Security 주요 모듈
Authentication
현재 접근하는 주체의 정보와 권한을 담는 인터페이스
public interface Authentication extends Principal, Serializable {
// 현재 사용자의 권한 목록
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
// 인증 여부
boolean isAuthenticated();
// 인증 여부 설정
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
SecurityContext
Authentication을 보관하는 역할
SecurityContext를 통해 Authentication 객체를 꺼내올 수 있음
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
SecurityContextHolder
보안 주체의 세부 정보를 포함하여 응용 프로그램의 현재 SecurityContext에 대한 세부 정보가 저장된다.
- 인증에 성공하면 principal과 credential 정보를 Authentication에 담는다.
- 그리고 Spring Security에서 Authetication을 SecurityContext에 보관한다.
- 보관한 SecurityContext를 SecurityContextHolder에 담아 보관한다.
UserDetails
스프링 시큐리티가 인증하는 데 사용하는 사용자 정보 클래스
인증에 성공하여 생성된 UserDetails 객체는 토큰을 생성하기 위해 사용된다.
엔티티에 UserDetails를 implements하여 처리한다.
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long idx;
private String email;
private String password;
private String nickName;
private String role;
private boolean enabled;
@OneToMany(mappedBy = "member")
private List<EmailVerify> emailVerifyList = new ArrayList<>();
@OneToMany(mappedBy = "member")
private List<Course> courseList = new ArrayList<>();
public void verify() {
this.enabled = true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
GrantedAuthority authority = new SimpleGrantedAuthority(role);
authorities.add(authority);
return authorities;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
}
UserDetailsService
스프링 시큐리티에서 사용자 정보를 조회하는 데 사용하는 클래스
UserDetails 객체를 반환하는 하나의 메서드만 가지고 있다.
일반적으로 이것을 UserRepository 를 주입받아 DB와 연결하여 처리한다.
@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
public void signup(MemberDto.SignupRequest dto) {
// 회원가입 로직
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByEmail(username);
if (result.isPresent()) {
Member member = result.get();
return member;
}
return null;
}
}
UsernamePasswordAuthenticationToken
Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로,
User의 ID가 Principal 역할을 하고 Password가 Credential의 역할을 한다.
UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두 번째는 인증이 완료된 객체를 생성한다.
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 620L;
private final Object principal;
private Object credentials;
// 인증 완료 전 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super((Collection)null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false);
}
// 인증 완료 후 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
}
AuthenticationManager
인증에 대한 부분은 AuthenticationManager를 통해서 처리하게 되는데,
실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider에 의해 처리된다.
인증에 성공하면 생성자를 이용해 객체를 생성하여 SecurityContext에 저장한다
@FunctionalInterface
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
AuthenticationProvider
AuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데,
인증 전의 Authentication 객체를 받아서 인증이 완료된 객체를 반환하는 역할을 한다.
스프링 시큐리티 로그인
LoginFilter | UserDetails | UserDetailsService | |
기존 | 세션 정보 저장 | username, password | 메모리에서 사용자 조회 |
변경 | 토큰 정보 응답 | enabled, nickname 등 추가 | DB에서 사용자 조회 |
인가 (Authorization)
누구인지 확인 후 권한을 부여해주는 것 (권한 부여)
ROLE
role로 사용자를 구분해서 특정 role로 접근이 가능한지 확인한다.
ex) USER role을 가진 사용자만 /a/ex01에 접근 가능
.requestMatchers("/a/ex01").hasRole("USER")
Enabled
엔티티에 enabled 속성을 추가해서 enabled를 통해 권한을 확인한다.
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long idx;
private String email;
private String password;
private String role;
private boolean enabled;
@Override
public boolean isEnabled() {
return enabled;
}
// 다른 메소드 생략
}
enabled가 false면 403 에러가 뜬다.
로그인 요청을 보냈을 때 enabled = 0이면 403 에러가 뜬다.
enabled가 true면 200 ok 응답이 온다.
로그인 요청을 보냈을 때 enabled = 1이면 200 OK로 응답이 잘 온다.
전체 구현 코드
📌 JwtUtil
토큰 생성
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
public class JwtUtil {
private static final String SECRET = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789";
private static final int EXP = 30 * 60 * 1000;
public static Member getMember(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(SECRET)
.build()
.parseClaimsJws(token)
.getBody();
return Member.builder()
.idx(claims.get("userIdx", Long.class))
.email(claims.get("userEmail", String.class))
.nickName(claims.get("userEmail", String.class))
.role(claims.get("userRole", String.class))
.build();
} catch (ExpiredJwtException e) {
System.out.println("토큰이 만료되었습니다!");
return null;
}
}
public static String generateToken(Long userIdx, String userEmail, String userNickName, String userRole) {
Claims claims = Jwts.claims();
claims.put("userRole", userRole);
claims.put("userEmail", userEmail);
claims.put("userNickName", userNickName);
claims.put("userIdx", userIdx);
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + EXP))
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
return token;
}
public static boolean validate(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(SECRET)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
System.out.println("토큰이 만료되었습니다!");
return false;
}
return true;
}
}
📌 LoginFilter
회원 정보 확인 후 로그인 처리
ATOKEN에 토큰 쿠키 설정
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Duration;
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// 원래는 form-data 형식으로 사용자 정보를 입력받았는데
// JSON 형태로 입력을 받기 위해서 재정의
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
System.out.println("LoginFilter 실행됐다.");
UsernamePasswordAuthenticationToken authToken;
try {
MemberDto.SignupRequest MemberDto = new ObjectMapper().readValue(request.getInputStream(), MemberDto.SignupRequest.class);
authToken =
new UsernamePasswordAuthenticationToken(MemberDto.getEmail(), MemberDto.getPassword(), null);
} catch (IOException e) {
throw new RuntimeException(e);
}
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
Member Member = (Member) authResult.getPrincipal();
String jwtToken = JwtUtil.generateToken(Member.getIdx(), Member.getEmail(), Member.getNickName(), Member.getRole());
// 일반적인 객체 생성 및 객체의 변수에 값을 설정하는 방법
// ResponseCookie cookie = new ResponseCookie();
// cookie.setPath("/");
// cookie.setHttpOnly(true);
// 빌더 패턴으로 객체를 생성하면서 값을 설정하는 방법
ResponseCookie cookie = ResponseCookie
.from("ATOKEN", jwtToken)
.path("/")
.httpOnly(true)
.secure(true)
.maxAge(Duration.ofHours(1L))
.build();
response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
}
📌 JwtFilter
쿠키에 설정된 토큰 확인 후 인가 처리
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class JwtFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
System.out.println("JwtFilter 실행됐다.");
Cookie[] cookies = request.getCookies();
String jwtToken = null;
if(cookies != null) {
for(Cookie cookie : cookies) {
if(cookie.getName().equals("ATOKEN")) {
jwtToken = cookie.getValue();
}
}
}
if(jwtToken != null) {
Member member = JwtUtil.getMember(jwtToken);
if(member != null) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(member, null, member.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(member);
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
SecurityConfig는 위에 있음!
'BE > Spring Boot' 카테고리의 다른 글
[Spring Boot] Swagger란? / Swagger API 사용하기 (0) | 2025.02.19 |
---|---|
[Spring Boot] SMTP로 구글 이메일 인증 기능 구현 (회원가입) (0) | 2025.02.19 |
[Spring Boot] 연관관계 매핑 (0) | 2025.02.10 |
[Java] Lombok이란? / 주요 어노테이션 정리 (0) | 2025.02.10 |
[Spring Boot] DTO 생성 방법 (단일 파일, 이너 클래스) / 생성 시 주의사항 (0) | 2025.02.10 |