0. 들어가기 전
- 버전
springboot : '3.2.3'
java : '17'
(강의 내용은 스프링부트 2, java 11 을 사용하지만, 본인이 refatoring 하였다.)
- Spring Security 사용 목적
스프링 시큐리티(Spring Security)는 스프링 기반의 애플리케이션에서 보안 관련 기능을 제공하는 Spring의 하위 프레임워크이다!
가장 강력하고, 중요한 이유는 인증(Authentication) 및 인가(Authorization)
스프링 시큐리티는 사용자의 인증 및 권한 부여를 처리하는 메커니즘을 제공한다. by Filter!!
스프링 시큐리티를 사용하지 않고, 코딩을 한다고 생각해보자..
매 요청마다 세션을 검사하고(Authenticaiton), 매 요청마다 특정 리소스에 접근할 권한 체크해야 한다.(Authorization)
1. 환경설정
1-1. 환경설정하기 / 코드 작성
1) Datagrip에 추가
(Intellij Ultimate ver. 사용하는 사람은 db 기능 사용해도 무관)
2) MySQL DB 및 사용자 생성
create user 'cos'@'%' identified by 'cos1234';
GRANT ALL PRIVILEGES ON *.* TO 'cos'@'%';
create database security;
use security;
3) 프로젝트 생성 (IntelliJ)
물론, Spring Initializr 홈페이지 사용해도 무관하다.
4) 의존성 추가
5) build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations{
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
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'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
tasks.named('test') {
useJUnitPlatform()
}
6) application.yml
server:
port: 8080
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
spring:
datasource:
url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
username: "root"
password:
driver-class-name: com.mysql.cj.jdbc.Driver
mustache:
prefix: classpath:/templates/
suffix: .html
jpa:
hibernate:
ddl-auto: update #create update none
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
머스테치를 사용하기 위해 설정해준다.
해당 plugin도 설치해야 한다.
7) com.example.security1.controller.IndexController.java
package com.example.security1.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller // view를 리턴하겠다
public class IndexController {
// localhost:8080
@GetMapping({"/"})
public String index() {
// 머스테치 기본폴더 src/main/resources/templates/
// view resolver 설정: templates (prefix), .html (suffix) 생략가능
return "index"; // src/main/resources/templates/index.html
}
}
내부적으로 view를 리턴하는 controller이기 때문에,
postman 등을 사용하려면 @RestController를 써야한다~~
(실제 프런트와 통신하는 경우 8,9 단계 필요 x, 테스트 용)
8) src/main/resources/temlpates/index.html
(백에서 테스트 위해 머스테치로 view 구성)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Index Page</title>
</head>
<body>
<h1>Hello, this is the index page!</h1>
</body>
</html>
1-2. 결과
1) 어플리케이션 Run 하면, 콘솔창에 security password가 뜨게 된다.
- http://localhost:8080 로 접근할 시 login 화면이 출력된다.
- Username: user, password는 콘솔 창의 security password
2. 시큐리티 설정
2-1. 시큐리티
1) SecurityConfig.java 권한 설정
@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable);
http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/user/**","/reissue").authenticated()
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGE")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
- "/user/**" 형식으로 시작하는 URL => 인증된 사용자에게만 허용
- "/manager/**" => "ROLE_ADMIN" 권한이 있거나 "ROLE_MANAGER" 권한이 있는 사용자에게만 허용
- "/admin/**" => "ROLE_ADMIN" 권한을 가진 사용자에게만 허용
hasAnyRole 같은 경우 "ROLE_" 생략
2) 권한 설정의 결과
3) /login 엔드포인트 비활성화
Spring Security는 기본적으로 "/login" 엔드포인트를 제공하며, 사용자 인증에 이 엔드포인트를 사용한다.
즉, 직접 SecurityConfig를 구성하고 "/login" 엔드포인트에 대한 설정을 추가하지 않았다면,
Spring Security는 자동으로 해당 엔드포인트를 활성화할 것이다.
위의 코드에서 "/login"에 대한 특별한 제약이나 비활성화 설정이 없다면,
"/login" 엔드포인트는 Spring Security에 의해 기본적으로 활성화될 것이다.
사용자는 해당 엔드포인트를 통해 로그인 페이지에 접근할 수 있어야 한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable);
// form 로그인 방식 비활성화
http
.formLogin(AbstractHttpConfigurer::disable);
http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/user/**","/reissue").authenticated()
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGE")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
}
3. 시큐리티 회원가입
3-1. 로그인 페이지 작성
1) loginForm.html 페이지 만들기
//IndexController.java 수정
@GetMapping("/loginForm")
public String loginForm() {
return "loginForm";
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form>
<input type="text" name="username" placeholder="username"/> <br/>
<input type="password" name="password" placeholder="password"/> <br/>
<button>로그인</button>
</form>
<a href = "joinForm"> 회원가입 하기</a> <!-- 이후 회원가입에 사용 -->
</body>
</html>
2) user 엔티티 작성
package com.example.security1.entity;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.*;
import java.sql.Timestamp;
@Data
@Entity
public class User {
@Id // primary key
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String email;
private String role; //ROLE_USER, ROLE_ADMIN
@CreationTimestamp
private Timestamp createDate;
}
run 후에 datagrip에서 reload하면 테이블이 만들어진 것을 볼 수 있다.
3-2. 회원가입
1) joinForm.html 페이지 만들기
//IndexController.java 수정
@GetMapping("/joinForm")
public String join() {
return "joinForm";
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입 페이지</h1>
<hr/>
<form action="/joinForm" method="POST"> <!-- /join으로 post -->
<input type="text" name="username" placeholder="Username"/> <br/>
<input type="password" name="password" placeholder="Password"/> <br/>
<input type="email" name="email" placeholder="Email"/> <br/>
<button>회원가입</button>
</form>
</body>
</html>
2) UserRepository 작성
// 기본적인 CRUD 함수 제공
public interface UserRepository extends JpaRepository<User, Integer> {
// Jpa Naming 전략
// SELECT * FROM user WHERE username = 1?
User findByUsername(String username);
// SELECT * FROM user WHERE username = 1? AND password = 2?
// User findByUsernameAndPassword(String username, String password);
// @Query(value = "select * from user", nativeQuery = true)
// User find마음대로();
}
그리고 아래와 같이 IndexController.java 수정 해주면 끝날 것 같다!
@PostMapping("/join")
public @ResponseBody String join(User user) {
user.setRole("ROLE_USER");
userRepository.save(user);
return "join";
}
🚨하지만, 비밀번호를 그대로 저장하는 것은 권장되지 않는다!
사용자의 비밀번호를 저장하기 전에 반드시 비밀번호를 안전하게 해싱 또는 인코딩해야 한다!
Spring Security는 암호화된 비밀번호를 사용하는데, PasswordEncoder를 사용하여 비밀번호를 해싱하는 것이 권장된다!!!
3) PasswordEncoder 추가
- 빈등록하기
//SecurityConfig 에 추가
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
- Controller 단에 비즈니스 로직을 구현하는 것은 좋지 않다.
Service에 만들어주자!
// IndexController.java
@RequiredArgsConstructor
@Controller // view를 리턴하겠다
public class IndexController {
private UserRepository userRepository;
// .. 중략
@PostMapping("/join")
public String join(@RequestBody User user) {
userService.register(user);
return "redirect:/loginForm";
}
}
- UserService 작성
@RequiredArgsConstructor
@Transactional
@Service
public class UserService{
private final UserRepository userRepository;
private BCryptPasswordEncoder bCryptPasswordEncoder;
public String register(User user) {
user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
user.setRole("ROLE_USER");
userRepository.save(user);
return "redirect:/loginForm";
}
}
해당 코드를 모두 작성하고 회원가입을 해보면,
아래와 같이 datagrip에 회원 정보가 들어온 것을 알 수 있다!
4. 시큐리티 로그인
4-1. 로그인 초기 설정
1) SecurityConfig 수정
// SecurityConfig 수정
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable);
http
.formLogin(AbstractHttpConfigurer::disable);
http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/user/**","/reissue").authenticated()
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGE")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
}
2) loginForm도 수정!
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<form id="login">
<input type="text" id="username" name="username" placeholder="username"/> <br/>
<input type="password" id="password" name="password" placeholder="password"/> <br/>
<button type="button" onclick="submitLoginForm()">로그인</button>
</form>
<a href="joinForm">회원가입 하기</a>
<script>
function submitLoginForm() {
var formData = {
username: document.getElementById("username").value,
password: document.getElementById("password").value
};
var xhr = new XMLHttpRequest();
xhr.open("POST", "/login", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify(formData));
}
</script>
</body>
</html>
( x-www.form-urlencoded 방식으로 온다. 그냥 hmtl 폼으로 보내면.)
( json 형식으로 파싱하도록 변경 )
4-2. PrincipalDetails와 PrincipalDetailsService
1) PrincipalDetails
- "SecurityConfig"에서 기본적으로 formlogin 방식을 사용할 때
login 주소가 호출이 되면 시큐리티가 가로채서 대신 로그인을 진행해준다고 했다.
- 다시말해, Spring Security가 /login EndPoint로 들어오는 요청이 오면, 가로채서 로그인을 진행!
(이후 로그인이 완료되면 Spring Security가 하는 일이다.)
- 로그인이 성공하면 Spring Security는 사용자의 인증 정보를 포함하는 Security Session을 생성한다.
- 이 세션은 SecurityContextHolder에 저장되어 현재 사용자의 인증 정보를 관리해준다!
- 즉, SecurityContextHolder를 이용해 세션을 스레드로컬에서 관리!!!!!!!!!!
- SecurityContextHolder가 가지고 있는 세션에 들어갈 수 있는 Object의 타입은 정해져 있다!
- 사용자의 인증 정보를 나타내는 객체를 Authentication 타입으로 만들어야 한다.
- Spring Security에서는 이 객체를 사용하여 사용자의 인증 및 권한을 관리한다.
- Authentication 객체 안에는 사용자 정보가 포함되어야 한다.
- 이는 주로 UserDetails 인터페이스를 구현한 객체가 됩니다.
휴... 이제 다 왔다! -> 이 userDetails Interface를 구현한 객체가 바로 "PrincipalDetails"
- UserDetails 인터페이스는 Spring Security에서 사용자의 세부 정보를 표현하는 인터페이스이다.
- 이 인터페이스를 구현한 객체는 사용자의 정보를 제공해야 한다!!
정리하자면,
- 로그인 성공 후 생성된 Security Session은 Authentication 객체를 가지고 있다고 했다.
- 이 Authentication 객체 안에는 UserDetails 타입의 객체가 있어야 한다.
- PrincipalDetails 클래스는 UserDetails를 구현한 구체적인 사용자 세부 정보를 나타내는 클래스이다.
Security Session -> Authentication -> User Detatils (구현체 : Principal Details)
- PricnipalDetails 코드
@Slf4j
public class PrincipalDetails implements UserDetails {
private final String username;
private final String password;
private final String roles;
public PrincipalDetails(String username, String password, String roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
// 해당 User의 권한을 리턴 하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(roles));
return authorities;
}
@Override
public String getUsername() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
// 사이트에서 1년 동안 회원이 로그인을 안하면 -> 휴면 계정으로 전환하는 로직이 있다고 치자
// user entity의 field에 "Timestamp loginDate"를 하나 만들어주고
// (현재 시간 - loginDate) > 1년 -> return false; 로 설정
return true;
}
}
(필요한 경우 아래 주석과 같이 처리!! - 현재 Test model이기 때문에 모두 true로 설정)
2) PrincipalDetailsService
- login 요청이 들어오면 자동으로 UserDetailsService 타입으로 IoC 되어있는 loadUserByUsername 함수가 실행된다.
- loadUserByUsername(String username)에서 username 매칭에 주의 하자!!
( 보내주는 json 형식이 중요! )
만약 다르다면???
1. html의 name = "username2" 에서 직접 username으로 수정해주거나,
2. Config filter Chain에서 .loginPage("/loginForm").usernameParameter("username2")를 추가해주자.
* 되도록이면, username을 쓰도록 하자...
- 이제, Username을 통해 user 객체를 가져올 것인데, 해당 내용은 Jpa 네이밍 방법을 찾아보자.
참고 :
// login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC 되어 있는 loadUserByUsername 함수가 실행된다.
// http://localhost:8080/login 요청이 올 때 이 PrincipalDetailsService의 loadUserByUsername가 동작한다!
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private UserRepository userRepository;
@Autowired
public PrincipalDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> userEntity = userRepository.findByUsername(username);
if (userEntity.isPresent()) {
User user = userEntity.get();
return new PrincipalDetails(user.getUsername(),user.getPassword(), user.getRole());
}
throw new UsernameNotFoundException("User not found with username: " + username);
}
}
4-3. 실행 결과
user인 경우 : => user 페이지는 정상 동작
5. 시큐리티 권한 처리
5-1. Test Data Setting
admin과 manager 아이디 하나 생성한 뒤
update user set role = 'ROLE_ADMIN' where id = 2;
update user set role = 'ROLE_MANAGER' where id = 3;
-> manager 아이디는 /manager 만 가지고,
-> admin 아이디는 /manager와 /admin이 가진다!!
5-2. 컨트롤러의 함수에 직접 권한 설정 하는 방법
// 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화 SecurityConfig.java에 설정
// PreAuthorize&PostAutrhorize, Secured 어노테이션 활성화
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
// Controller에 어노테이션 거는 법
@PostAuthorize("hasRole('ROLE_MANAGER')")
@PreAuthorize("hasRole('ROLE_MANAGER')")
@Secured("ROLE_MANAGER")
PostAuthorize는 거의 안쓰고, 요즘에는 Secured 를 많이 써서, PreAuthorize도 잘 안쓰인다...
+ Error (FilterChain 방식으로 수정)
<WebSecurityConfigurerAdapter Deprecated>
@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// csrf disable
http
.csrf(AbstractHttpConfigurer::disable);
// form 로그인 방식 disable
http
.formLogin(AbstractHttpConfigurer::disable);
// 경로별 인가
http.
authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/user/**","/reissue").authenticated()
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGE")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
}