Open seungriyou opened 9 months ago
서블릿 컨테이너 내에는 필터가 존재하여 요청이 필터를 거쳐서 들어오게 된다.
Spring Security Config는 필터를 만들어서, 어떤 요청이 어떤 컨트롤러에 권한이 있는지를 확인한다.
현재는 스프링 시큐리티가 전체 페이지에 적용되어 있다!
추후 시큐리티 config 클래스를 생성해서 어떤 페이지에 적용할지 지정해야 한다.
다음과 같이 스프링 시큐리티에서 비밀번호를 제공해주는데, 이때 id는 user
로 작성하면 로그인이 가능하다.
특정한 경로에 요청이 오면 Controller 클래스에 도달하기 전 필터에서 Spring Security가 검증을 한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 람다식으로 작성
// 작성 순서대로 우선순위 부여되므로 주의
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/login").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.requestMatchers("/my/**").hasAnyRole("ADMIN", "USER") // wildcard 사용 가능
.anyRequest().authenticated() // 로그인 한 사용자들은 접근 가능하도록
);
return http.build(); // 빌드해서 리턴
}
}
admin 페이지도 생성해보았다. 아직 role 관련 기능을 구현하지 않았으므로 액세스 거부가 뜬다!
스프링 시큐리티의 경우, 버전별로 구현 방법이 많이 다르므로 버전마다 구현 특징을 확인해야 한다.
스프링 부트 2.7.X 버전부터 SecurityFilterChain
인터페이스를 리턴하는 스프링 빈 등록해야 하는 형태로
스프링 부트 3.1.X 버전부터 람다 형식 표현 필수!
public class SpringSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/join").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
아이디, 비밀번호 → POST 요청 경로
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
// === 로그인 === //
http
.formLogin((auth) -> auth.loginPage("/login") // 로그인 페이지 경로 (admin 페이지 접근 시 자동으로 리다이렉트)
.loginProcessingUrl("/loginProc") // POST 방식으로 로그인 데이터를 넘긴다.
.permitAll() // 누구나 들어올 수 있다.
);
// 개발 환경에서는 잠시 disable 시켜두자.
http
.csrf((auth) -> auth.disable()); // 스프링 시큐리티에서는 기본적으로 csrf 설정이 되어있는데, 이러면 POST 시 csrf 토큰도 보내주어야 한다.
return http.build(); // 빌드해서 리턴
}
}
스프링 시큐리티는 사용자 인증(로그인)시 비밀번호에 대해 단방향 해시 암호화를 진행하여 저장되어 있는 비밀번호와 대조한다.
따라서 회원가입시 비밀번호 항목에 대해서 암호화를 진행해야 한다.
단방향 해시 암호화
- 양방향
- 대칭키
- 비대칭키
- 단방향
- 해시
스프링 시큐리티는 암호화를 위해 BCrypt Password Encoder를 제공하고 권장한다. 따라서 해당 클래스를 return하는 메소드를 만들어 @Bean
으로 등록하여 사용하면 된다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
...
회원 정보를 저장하기 위한 데이터베이스는 MySQL 엔진의 데이터베이스를 사용한다. 그리고 접근은 Spring Data JPA를 사용한다.
application.yml
에 다음을 작성한다.
spring:
datasource:
# jdbc:mysql://아이피:3306/데이터베이스?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
url: jdbc:mysql://localhost:3306/practice?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
username: sryou
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
applications.yml
jpa:
hibernate:
ddl-auto: create
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
@Getter
@Setter
public class JoinDTO {
private String username;
private String password;
}
@Controller
public class JoinController {
// TODO: 추후 생성자 주입으로 변경 필요
@Autowired
private JoinService joinService;
@GetMapping("/join")
public String joinP() {
return "join";
}
@PostMapping("/joinProc")
public String joinProcess(JoinDTO joinDTO) {
System.out.println("joinDTO.getUsername() = " + joinDTO.getUsername());
joinService.joinProcess(joinDTO);
return "redirect:/login";
}
}
@Service
public class JoinService {
// TODO: 추후 생성자 주입으로 변경 필요
@Autowired
private UserRepository userRepository;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
public void joinProcess(JoinDTO joinDTO) {
// TODO: db에 이미 동일한 username을 가진 회원이 존재하는지 확인
User user = new User();
user.setUsername(joinDTO.getUsername());
user.setPassword(bCryptPasswordEncoder.encode(joinDTO.getPassword()));
user.setRole(Role.USER);
userRepository.save(user);
}
}
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
}
public interface UserRepository extends JpaRepository<User, Long> {
}
추가!
http
// 람다식으로 작성
// 작성 순서대로 우선순위 부여되므로 주의
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/login", "/loginProc", "/join", "/joinProc").permitAll() // 모두 접근 가능
.requestMatchers("/admin").hasRole("ADMIN")
.requestMatchers("/my/**").hasAnyRole("ADMIN", "USER") // wildcard 사용 가능
.anyRequest().authenticated() // 로그인한 사용자들은 접근 가능하도록
);
username에 대해서 중복된 가입이 발생하면 서비스에서 아주 치명적인 문제가 발생하기 때문에 백엔드단에서 중복 검증과 중복 방지 로직을 작성해야 한다.
프론트에서도, 백에서도 검증해야 한다!
- 엔티티
@Column(unique = true)
```java
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
}
```
리포지토리 & 서비스
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username);
}
public void joinProcess(JoinDTO joinDTO) {
// db에 이미 동일한 username을 가진 회원이 존재하는지 확인
boolean isDuplicate = userRepository.existsByUsername(joinDTO.getUsername());
if (isDuplicate) {
return;
}
...
아이디, 비밀번호에 대한 정규식 처리가 필요하다.
서비스: CustomUserDetailsService
(인터페이스 = UserDetailsService
)
@Service
public class CustomUserDetailsService implements UserDetailsService {
// TODO: 추후 생성자 주입으로 변경 필요
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user != null) {
return new CustomUserDetails(user);
}
return null;
}
}
DTO: CustomUserDetails
(인터페이스 = UserDetails
)
public class CustomUserDetails implements UserDetails {
private User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
// === 이번 예제에서는 강제로 만료되지 않았다고 true를 설정해둔다. === //
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
[!note] 궁금증
현재 서비스에서
user.setRole("ROLE_ADMIN")
으로 설정하는데,.requestMatchers("/admin").hasRole("ADMIN")
이 동작하는 이유는 무엇일까?→
ROLE_권한명
에서 선행되는ROLE_
의 경우 스프링 시큐리티에서 필수적으로 사용하는 접두사 입니다.security config 설정시에는 권한명만 명시하면 자동으로 생성하며, DB 기반 커스텀 인증시
ROLE_
이라는 접두사를 필수적으로 붙여야하기 때문에 DB 저장시 접두사를 붙여 저장을 합니다. (DB에서 붙이지 않고 UserDetails에서 처리를 진행하셔도 되지만 편의상 DB에 접두사를 함께 저장합니다.)추가로
ROLE_
접두사를 변경하시고 싶으면 아래 메소드를@Bean
으로 등록하시면 됩니다.@Bean static GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults("MYPREFIX_"); }
role을
ENUM
으로 관리하는 것이 좋을까?
원래는 별도로 서비스를 만들어야 하나, 컨트롤러에서 간단히 확인했다.
- 세션 현재 사용자 ID
```java
String id = SecurityContextHolder.getContext().getAuthentication().getName();
```
세션 현재 사용자 role
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iter = authorities.iterator();
GrantedAuthority auth = iter.next();
String role = auth.getAuthority();
사용자가 로그인을 진행한 뒤 사용자 정보는 SecurityContextHolder
에 의해서 서버 세션에 관리된다.
세션 아이디는 사용자에게 쿠키로 반환된다.
이때 세션에 관해 세션의 소멸 시간, 아이디당 세션 생성 개수를 설정하는 방법에 대해서 알아보자.
JWT의 경우, 세션에서 관리하지 않는 stateless 방법이므로 해당되지 X
세션 타임아웃 설정을 통해 로그인 이후 세션이 유지되고 소멸하는 시간을 설정할 수 있다.
세션 소멸 시점은 서버에 마지막 특정 요청을 수행한 뒤 설정한 시간 만큼 유지된다. (기본 시간 1800초)
application.yml
server:
servlet:
session:
timeout: 90m # default: 1800 (1800s)
https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
동일한 아이디로 다중 로그인을 진행할 경우에 대한 설정은 세션 통제를 통해 진행한다.
이때, Security Config에서 sessionManagement()
메소드를 사용한다.
// 다중 로그인 관련
http
.sessionManagement((auth) -> auth
.maximumSessions(1) // 하나의 아이디에 대한 다중 로그인 허용 개수
.maxSessionsPreventsLogin(true)); // true: 초과 시 새로운 로그인 차단 / false: 초과 시 기존 세션 하나 삭제
maximumSession(정수)
: 하나의 아이디에 대한 다중 로그인 허용 개수maxSessionPreventsLogin(불린)
: 다중 로그인 개수를 초과하였을 경우 처리 방법
true
: 초과시 새로운 로그인 차단false
: 초과시 기존 세션 하나 삭제[!note] 나는 중복 로그인이 가능하다…! 이거 뭐지?
해커가 세션 쿠키를 탈취하면 admin 권한을 가질 수 있게 된다.
세션 고정 공격을 보호하기 위한 로그인 성공시 세션 설정 방법은 sessionManagement()
메소드의 sessionFixation()
메소드를 통해서 설정할 수 있다.
sessionManagement().sessionFixation().none()
: 로그인 시 세션 정보 변경 안함
위험하다!
sessionManagement().sessionFixation().newSession()
: 로그인 시 세션 새로 생성
로그인 시 세션을 새로 생성하여 세션 id 값이 변경된다.
sessionManagement().sessionFixation().changeSessionId()
: 로그인 시 동일한 세션에 대한 id 변경
세션은 동일하지만 세션 아이디(쿠키 내) 값이 달라진다.
공식 문서 코드
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) - session
.sessionFixation((sessionFixation) -> sessionFixation
.newSession()
)
);
return http.build();
}
구현 코드
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
.sessionManagement((auth) -> auth
.sessionFixation().changeSessionId());
return http.build();
}
CSRF(Cross-Site Request Forgery)는 요청을 위조하여 사용자가 원하지 않아도 서버측으로 특정 요청을 강제로 보내는 방식이다. (회원 정보 변경, 게시글 CRUD를 사용자 모르게 요청)
개발 환경에서는 Security Config 클래스를 통해 csrf 설정을 disable 설정하였다.
배포 환경에서는 csrf 공격 방지를 위해 csrf disable 설정을 제거하고 추가적인 설정을 진행해야 한다.
[!note] Security Config에 관련 설정을 하지 않으면 default로 enable 설정이 된다.
따라서 로그인을 해보면 진행되지 않는다.
security config 클래스에서 csrf.disable()
설정을 진행하지 않으면 자동으로 enable 설정이 진행된다. enable 설정시 스프링 시큐리티는 CsrfFilter
를 통해 POST, PUT, DELETE 요청에 대해서 CSRF 토큰 검증을 진행한다.
security config 클래스에서 csrf.disable()
구문을 삭제한다.
POST 요청에서 설정하기
mustache 기준
서버 측에서 받은 csrf 토큰을 다시 보내주어야 한다.
<form action="/loginReceiver" method="post" name="loginForm">
<input type="text" name="username" placeholder="아이디"/>
<input type="password" name="password" placeholder="비밀번호"/>
<input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
<input type="submit" value="로그인"/>
</form>
application.yml
: spring.mustache.servlet.expose-request-attributes=true 설정 추가
ajax 요청 시 (비동기)
HTML
구획에 아래의 요소를 추가한다.<meta name="_csrf" content="{{_csrf.token}}"/>
<meta name="_csrf_header" content="{{_csrf.headerName}}"/>
GET 방식 로그아웃을 진행할 경우
csrf 설정시 POST 요청으로 로그아웃을 진행해야 하지만 아래 방식을 통해 GET 방식으로 진행할 수 있다.
security config
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
.logout((auth) -> auth.logoutUrl("/logout")
.logoutSuccessUrl("/"));
return http.build();
}
로그아웃용 컨트롤러 따로 생성
@Controller
public class logoutController {
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication != null) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
return "redirect:/";
}
}
csrf.disable()
해도 괜찮다!앱에서 사용하는 API 서버의 경우 보통 세션을 STATELESS로 관리하기 때문에 스프링 시큐리티 csrf enable 설정을 진행하지 않아도 된다.
JWT 등을 사용한다면 stateless
토이 프로젝트를 진행하는 경우 또는 시큐리티 로그인 환경이 필요하지만 소수의 회원 정보만 가지며 데이터베이스라는 자원을 투자하기 힘든 경우는 회원가입 없는 InMemory 방식으로 유저를 저장하면 된다.
이 경우 InMemoryUserDetailsManager
클래스를 통해 유저를 등록하면 된다.
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/in-memory.html
1~5장에서 작성한 코드를 기반으로, SecurityConfig
에 다음과 같은 스프링 빈을 추가한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails user1 = User.builder()
.username("user1")
.password(bCryptPasswordEncoder().encode("1234"))
.roles("ADMIN")
.build();
UserDetails user2 = User.builder()
.username("user2")
.password(bCryptPasswordEncoder().encode("1234"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user1, user2);
}
}
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html
기존의 .formLogin()
부분을 삭제하고, 다음을 추가한다.
// httpBasic 방식
http
.httpBasic(Customizer.withDefaults());
https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html
권한 A, 권한 B, 권한 C가 존재하고 권한의 계층은 “A < B < C”라고 설정을 진행하고 싶은 경우 RoleHierarchy 설정을 진행할 수 있다.
기존 방식
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.csrf((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login").permitAll()
.requestMatchers("/").hasAnyRole("A", "B", "C")
.requestMatchers("/manager").hasAnyRole("B", "C")
.requestMatchers("/admin").hasAnyRole("C")
.anyRequest().authenticated()
);
http
.formLogin((auth) -> auth.loginPage("/login")
.loginProcessingUrl("/loginProc")
.permitAll()
);
return http.build();
}
계층 권한 메소드 등록
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_C > ROLE_B\n" +
"ROLE_B > ROLE_A");
return hierarchy;
}
메소드 적용을 통한 ROLE 적용
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.csrf((auth) -> auth.disable());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login").permitAll()
.requestMatchers("/").hasAnyRole("A")
.requestMatchers("/manager").hasAnyRole("B")
.requestMatchers("/admin").hasAnyRole("C")
.anyRequest().authenticated()
);
http
.formLogin((auth) -> auth.loginPage("/login")
.loginProcessingUrl("/loginProc")
.permitAll()
);
return http.build();
}
Contents
H2 URL
그냥 MySQL로 바꿨다... #17