1. 의존성
프로젝트에 사용한 의존성은 다음과 같다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mustache'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
2. DB 연결 (MySQL)
시작에 앞서 DB를 연결해야 한다. mysql을 spring boot에 연결할 때 properties.yaml 파일에 다음과 같이 작성해주면 된다.
spring:
application:
name: demo
datasource:
driver-class-name: ${db.driver-class-name}
url: ${db.url}
username: ${db.username}
password: ${db.password}
config:
import: application-db.yml
- db.driver-class-name: com.mysql.cj.jdbc.Driver
- db.url: jdbc:mysql://{아이피 주소}:3306/{데이터베이스 이름}?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
- db.username: 유저 이름
- db.password: 유저 패스워드
3. Security Config 클래스
3.1 기본 작성 방법 (+authorizeHttpRequest)
유저가 서버에 요청을 보내면 서블릿 컨테이너를 지나서, 스프링부트 에플리케이션에 들어간다. 이때 서블릿은 여러 개의 필터를 가지는데, 요청은 이 필터를 통과 후 스프링부트 에플리케이션으로 전달된다. 여기서 스프링 시큐리티를 의존성으로 추가하면 이 필터에서 스프링 시큐리티가 해당 요청을 가로챈다. 그래서 클라이언트가 특정 요청을 보낼 때 그 유저가 로그인이 되어 있는지, 접근 권한이 있는지, 특정한 role을 가지고 있는지에 대해 검증을 하게 된다. 이런 작업을 인가( Authentication) 작업이라고 한다. 이런 인가 작업을 하기 위해서 Security Config 클래스를 등록해야 한다. 먼저 아래와 같이 config 파일 안에 Security Config 파일을 생성해줬다.
코드는 다음과 같이 작성했다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/mainPage", "/login", "/signUp").permitAll()
.requestMatchers("/adminPage").hasRole("ADMIN")
.requestMatchers("/myPage/**").hasAnyRole("ADMIN", "USER")
)
return httpSecurity.build();
}
}
- @EnableWebSecurity: 스프링 시큐리티 설정을 정의한 구성 클래스에 사용하며 보통 @Configuration 어노테이션과 함께 사용한다. @EnableWebSecurity 어노테이션은 자동으로 스프링 시큐리티 피터 체인을 생성하고 웹 보안을 활성화 한다. 이때 웹 보완을 활성화한다는 의미는 스프링 시큐리티의 필터 체인이 동작하여 요청을 인가하고 인증하는 것을 말한다.
- HttpSecurity: 스프링 시큐리티의 각종 설정을 담당한다. 리소스(URL) 접근 권한 설정, 인증 실패시 이동 페이지 설정, 인증 로직 커스텀을 위한 필터 설정 등이 가능하다.
- authorizeHttpRequest를 통해 permitAll, hasRole, hasAnyRole, authenticated , denyAll 등 특정 url들에 대한 접근 방법을 설정할 수 있다. 동작되는 순서는 상단부터 적용되므로, 순서를 주의해서 적어야 한다.
위 코드에서 "http://localhost:8080/mainPage"에 대한 요청은 permitAll()로 설정했는데, 이와 같이 설정하면 아래와 같이 /login 페이지로 넘어가지 않고 해당 요청에 대한 응답을 받을 수 있다.
반면 "http://localhost:8080/adminPage"를 요청하면 로그인을 한 적도 없고, 당연히 role도 가지고 있지 않기 때문에 403 error가 발생한다. 이는 httpSecurity.formLogin을 설정하지 않았기 때문이다.
3.2 formLogin, csrf
formLogin은 다음과 같이 작성된다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/mainPage").permitAll()
.requestMatchers("/login").permitAll()
.requestMatchers("/adminPage").hasRole("ADMIN")
.requestMatchers("/myPage/**").hasAnyRole("ADMIN", "USER")
)
.formLogin((form) -> form
.loginPage("/login")
.loginProcessingUrl("/login")
.permitAll());
return httpSecurity.build();
}
- .loginPage(): 로그인 페이지 주소를 설정한다.
- .loginProcessingUrl(): html에서 form 태그의 action 속성은 서버로 form data를 보낼 때 해당 데이터가 도착할 URL을 명시한다. 이때 로그인 form 태그에도 당연히 해당 속성이 존재하는데 이 url을 넘겨준다.
근데 위 코드를 보면 csrf를 disable한 코드가 있다. 그럼 csrf란 뭘까? csrf(Cross Site Request Forgery)란 인증된 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 말한다. 예를 들어, 사용자가 특정 하이퍼 링크를 클릭하면 자동으로 요청이 가도록 하는 방식의 공격 기법이다. 이러한 csrf 공격에 대한 보호가 spring security는 기본으로 제공된다. 이때 Get 요청을 제외한 Post, Put, Delete 등의 요청으로부터 헤더에 csrf 토큰이 포함되어야 요청을 받아들이게 된다. 검증 과정은 다음과 같다.
- 요청 세션에서 CSRF Token을 조회(loadToken)한다. 만약 조회된 CSRF Token이 없으면 CSRF Token을 발급(generateToken)한다.(생성되는 토큰은 UUID.randomUUID().toString()으로 생성한다.) 발급된 토큰은 서버에 저장한다.
- csrf 검증 메서드를 체크한다. GET, HEAD, TRACE, OPTIONS를 제외한 모든 메서드에 대해 검증을 수행하고 아니라면 다음 Filter로 넘긴다.
- 요청 헤더에 X-CSRF-TOKEN 값이 있는지 확인한다. 없는 경우 바디에 _csrf 값이 있는지 확인한다.
- 토큰값과 클라이언트에서 보낸 토큰 값을 비교하여 일치하면 다음 필터를 호출한다. 일치하지 않으면 accessDeniedHandler 메서드를 호출한다.
즉, csrf 보호 기능을 사용하면 post 기능을 사용하기 위해 헤더에 csrf 토큰을 보내줘야 한다. 하지만 지금은 일단 간단하게 diable 후 진행한 것이다. (login processing url로 이동할 때 post 메서드를 사용하기 때문에 disable하지 않으면 문제가 발생한다.)
4. BCrypt 암호화
스프링 시큐리티는 로그인시 비밀번호에 대해 해시 암호화를 진행하여 저장된 비밀번호와 대조한다. 따라서 회원가입시 비밀번호에 대해 암호화를 진행해야 한다. 스프링 시큐리티는 암호화를 위해 BCrypt Password Encoder를 제공하고, 권장하기 때문에 해당 클래스를 리턴하는 메소드를 만들고 @Bean으로 등록하면 된다. 아래와 같이 작성하면 된다.
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
위와 같이 Bean으로 등록했기 때문에 회원 가입시 암호화를 진행할 때 사용할 수 있다.
5. 회원가입 구현
회원가입을 위해 먼저 멤버 클래스를 다음과 같이 선언했다.
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int member_id;
@NonNull
@Column(unique = true)
private String username;
@NonNull
private String password;
@NonNull
private String role;
}
또한 회원가입 요청 시 보낼 데이터를 다음과 같이 선언했다.
@Setter
@Getter
public class SignUpRequest {
private String username;
private String password;
}
위처럼 선언한 멤버 클래스를 JPA가 자동으로 테이블을 생성하도록 하기 위해 아래와 같이 application.yaml 파일의 설정을 추가해준다.
spring:
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
- ddl-auto를 사용하면 이전에 작성된 테이블 내용을 모두 지우고 새로운 테이블을 생성한다. 따라서 회원 정보 저장 후 이 데이터를 삭제하고 싶지 않다면 update가 아닌 none으로 해주면 된다.
유저를 저장하기 위해서는 먼저 MemberRepository를 선언해야 한다. 유저간 동일한 username을 가지면 안되므로 이를 체크하기 위해 existsByUsername(String username)을 선언해주었다.
public interface MemberRepository extends JpaRepository<Member, Integer> {
boolean existsByUsername(String username);
Optional<Member> findByUsername(String username);
}
- findByUsername은 이후 로그인 과정에서 사용할 메서드다.
그리고 회원가입 서비스를 처리할 SignUpService 클래스는 다음과 같이 작성했다.
@Service
@RequiredArgsConstructor
public class SignUpService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
public boolean signUpProc(SignUpRequest request) {
boolean isExisted = memberRepository.existsByUsername(request.getUsername());
if (isExisted) {
return false;
}
Member member = Member.builder()
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.role("ROLE_USER")
.build();
memberRepository.save(member);
return memberRepository.existsByUsername(member.getUsername());
}
}
- passwordEncoder는 위에서 Bean으로 등록한 비밀번호 암호화 객체이다.
- 먼저 현재 요청에서 받아온 username이 이미 존재하는지 확인하고, 존재하지 않는다면 member 객체를 생성하여 db에 저장한다.
마지막으로 회원가입 요청을 처리하는 컨트롤러의 코드는 다음과 같다.
@PostMapping("/signUp")
public String signUpProc(SignUpRequest request) {
signUpService.signUpProc(request);
return "redirect:/login";
}
6. 로그인 구현(UserDetailsService, UserDetails)
처음에 로그인 요청이 들어오면 spring security config는 DB에 있는 유저 정보를 조회하여 요청된 데이터와 비교해야 한다. 이때 DB에 저장된 데이터와 로그인 데이터를 검증하기 위해 UserDetailsService와 UserDetails를 구현해야 한다. 먼저 UserDetailsService는 유저 객체를 DB에서 어떻게 가져올 것인지를 구현한다고 보면 된다. 코드 내용은 다음과 같다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> memberOptional = memberRepository.findByUsername(username);
if (memberOptional.isPresent()) {
return new CustomUserDetails(memberOptional.get());
}
return null;
}
}
- 로그인 요청 데이터의 username이 db에 존재하는지 확인한다. 존재한다면, UserDetails를 반환하고 없다면 null을 반환한다.
UserDetails의 경우 유저의 username, password, role 등을 어떻게 가져올지를 구현해야한다. 또한 해당 유저의 계정이 만료되었거나, 인증이 만료되었는지 등을 확인할 수 있는 코드도 구현해야 한다. 현재 코드에서는 간단히 어떻게 username, password, role 등을 가져올지만 구현했다.
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {
private Member member;
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return member.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
}
이후 로그인된 사용자의 정보를 가져오고 싶다면, 아래와 같이 가져올 수 있다.
7. 세션 설정 (SecurityContextHolder)
사용자가 로그인을 진행하면 사용자 정보는 서버 세션에서 SecurityContextHolder에 의해 관리된다. SecurityContextHolder를 통해 다음과 같이 로그인한 사용자 객체를 가져올 수 있다.
SecurityContextHolder.getContext().getAuthentication().getName());
이때 세션에 대해 소멸 시간, 아이디당 세션 개수 등을 설정할 수 있다.
7.1. 세션 소멸 시간 설정
세션 소멸 시간의 경우 다음 코드를 properties.yaml에 다음 내용을 추가하면 된다.
server:
servlet:
session:
timeout: 30m
- 초 단위는 1800과 같이 명시하면 되고, 분은 30m과 같이 뒤에 m을 붙여주면 된다.
7.2. 다중 로그인 설정 (sessionManagement)
다중 로그인의 경우 sessionManagement() 메서드를 통해 설정할 수 있다. 이전에 작성한 securityFilter 체인의 httpSecurity에 다음 코드를 추가하면 된다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
...
.sessionManagement((session) -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(true));
return httpSecurity.build();
}
- maximumSession(): 하나의 아이디에 대한 다중 로그인 허용 개수를 설정한다.
- maxSessionPreventsLogin(): 다중 로그인 개수를 초과했을 경우 처리 방벙을 설정한다. true시 새로운 로그인을 차단하고, false시 기존 세션 하나를 삭제한다
7.3. 세션 고정 보호 (sessionManagement)
세션 고정 공격은 다음과 같은 과정을 따른다.
- 공격자가 사용자의 컴퓨터에 자신의 세션 쿠키를 저장시킨다.
- 사용자가 해당 세션 쿠키를 통해 로그인을 한다.
- 이후 공격자는 사용자의 세션 쿠키와 같으므로 사용자의 정보를 조회하거나 사용자의 권한을 통해 특정 행위를 수행할 수 있다.
이때 다음과 같은 코드를 추가하면 된다.
httpSecurity
.sessionManagement((session) -> session
.sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::newSession)
);
- sessionFixation.none(): 로그인 시 세션 정보를 변경하지 않는다.
- sessionFixation.newSession(): 로그인 시 세션을 새로 생성한다.
- sessionFixation.changeSessionId(): 로그인 시 동일한 세션에 대한 id를 변경한다.
'BackEnd > Spring Security' 카테고리의 다른 글
[Spring Security] 내부 및 인증 동작 원리 (0) | 2024.09.10 |
---|