본 글은 인프런 "스프링부트 시큐리티 & JWT 강의" 최주호 강사님의 강의를 바탕으로 작성한 글입니다.
스프링 시큐리티란?
공식문서에는 아래와 같이 설명되어 있다.
Spring Security는 강력하고 사용자 정의가 가능한 인증 및 엑세스 제어 프레임워크입니다. 이는 Spring 기반 애플리케이션 보안을 위한 사실상의 표준입니다.
Spring Security는 Java 애플리케이션에 인증과 권한 부여를 모두 제공하는 데 중점은 둔 프레임워크입니다.
따라서 java 애플리케이션으로 인증/권한 부여를 하기 위해서 Spring Security를 많이 사용한다.
1. 환경설정
강의에서는 mushtache라는 템플릿 엔진을 사용했다.
의존성은 아래와 같이 추가해 주었다
- Lombok
- Spring Boot DevTools
- Spring Web
- Spring Data JPA
- MySQL Driver
- Spring Security
- Mushtache
스프링 시큐리티 의존성을 추가하게 되면 프로젝트를 실행하고 localhost:8080으로 들어가도 시큐리티가 모든 주소 접근을 막아놔서 인증을 요구한다. 아래 사진처럼 username과 password를 입력하라고 뜬다.
프로젝트를 실행하면 아래 사진과 같이 콘솔에 password가 뜨는게 그걸 복붙하면 된다.
username에는 user를 입력하면 된다.
매번 실행할 때마다 인증을 요구하면 콘솔에서 패스워드를 찾아서 복붙하는게 여간 귀찮은 일이 아니다...
설정 파일에서 username과 password를 설정해주면 해결된다.
yml파일을 기준으로 작성하자면 아래와 같다.
spring:
security:
user:
name: [설정할 username]
password: [설정할 password]
이렇게 설정하고 나면 스프링부트를 실행해도 console에 password가 나오지 않는다. 그리고 사용자가 설정한 name과 password를 입력하면 된다.
인증을 하고나면 templates/index.html 파일이 뜬다.
2. 시큐리티 설정
SecurityConfig 클래스에 시큐리티 설정을 해주었다.
@Configuration
@EnableWebSecurity //스프링 시큐리티 필터(SecurityConfig)가 스프링 필터체인에 등록된다.
@EnableMethodSecurity(securedEnabled=true, prePostEnabled = true) //강의에서는 @EnableGlobalMethodSecurity를 사용했으나 지금은 Deprecated여서 EnableMethodSecurity로 변경
public class SecurityConfig{
@Autowired
private PrincipalOauth2UserService principalOauth2UserService;
//패스워드 암호화
//해당 메서드의 리턴되는 오브젝트를 IoC로 등록해준다.
@Bean
public BCryptPasswordEncoder encodePwd(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf((csrfConfig) ->
csrfConfig.disable()
);
http
.authorizeRequests()
.requestMatchers("/user/**").authenticated() //이런 주소로 들어오면 인증이 필요함
.requestMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") //매니저로 들어오면 hasAnyRole에 적혀있는 권한이 있는사람만 들어올 수 있다.
.requestMatchers("/admin/**").hasRole(Role.ADMIN.name()) //ROLE_ADMIN 권한이 있는 사람만 들어올 수 있다.
.anyRequest().permitAll()
.and()
.formLogin(formLogin -> formLogin
.loginPage("/loginForm")//-> 권한이 없는 페이지에 접근했을 때 /login으로 이동
.loginProcessingUrl("/login") // /login 주소가 호출되면 시큐리티가 낚아채서 대신 로그인 진행 controller에 /login을 따로 만들지 않아도됨
.defaultSuccessUrl("/user") //로그인 성공하면 /user로 이동
);
return http.build();
}
}
- `@EnableWebSecurity` : SecurityConifg 클래스가 Bean으로 스프링 필터 체인에 등록된다.
- `csrf((csrfConfig) ->csrfConfig.disable()` : csrf를 무시하기 위한 설정이다. csrf에 대한 내용을 여기서 설명하면 글이 너무 길어질 거 같으므로 따로 작성하기로...
- `authorizeRequests().requestMathers()` : 특정한 경로로 들어오는 요청에 대해서 권한을 요구할 수 있다.
- `authorizeRequests().anyRequest().permitAll()` : 권한을 요구하는 요청 외의 요청은 모든 사용자가 접근할 수 있다.
- `authorizeRequests().requestMathers().hasRole()` : 특정 권한을 가진 사람만 접근 허용.
- 위 코드에서는 `/admin/**` 요청은 admin인 권한을 가진 사람만 접근할 수 있다.
- `authorizeRequests().requestMathers().access()` : 특정 권한을 가진 사람만 접근 허용. 복수 허용 가능.
- 위 코드에서는 mamager와 admin 권한인 사람을 허용함.
- `http.formLogin()` : form 태크 기반의 로그인일 지원하는 함수이다.
- `loginPage()` : 사용자가 권한이 없는 요청을 한다면 리다이렉트 될 url을 지정해준다.
- 위 코드에서는 /loginForm으로 이동한다.
- `loginProcessingUrl()` : 지정한 요청이 오면 시큐리티가 낚아채서 대신 로그인 진행하게끔한다.
- 위 코드에서는 `/login` 요청이오면 자동으로 로그인 진행하게끔 한다. 컨트롤러에 login을 따로 만들지 않아도 된다.
- `defaultSuccessUrl()` : 로그인이 성공하면 이동할 url 지정.
- 위 코드에서는 `/user`로 이동한다.
3. 시큐리티 회원가입
회원가입을 위해 컨트롤러와 html을 구현해보자
IndexController.java
@Controller
public class IndexController {
@Autowired
private UserRepository userRepository;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@GetMapping("/joinForm")
public String joinForm() {
return "joinForm";
}
@PostMapping("/join")
public String join(User user) {
System.out.println("user = " + user);
user.setRole("ROLE_USER");
//패스워트 암호화 해주기
String rawPassword = user.getPassword();
String encPassword = bCryptPasswordEncoder.encode(rawPassword);
user.setPassword(encPassword);
userRepository.save(user); //비밀번호를 암호화해서 저장해야함 그냥 생비밀번호를 저장하면 시큐리티로 로그인을 할 수 없다.
return "redirect:/loginForm";
}
}
- `BcryptPasswordEncoder` : 스프링 시큐리티가 제공하는 클래스 중 하나로 비밀번호를 암호화하는 데 사용할 수 있는 메서드를 가진 클래스이다.
- bCryptPasswordEncoder.encode(password)로 비밀번호 암호화하여 DB에 저장한다. 암호화하지 않고 저장할 경우 사용자의 정보가 노출되면 비밀번호도 그대로 노출될 위험이 있기 때문이다.
joinForm.index
<body>
<h1>회원가입 페이지</h1>
<hr/>
<form action="/join" method="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>
4. 시큐리티 로그인
`/login` 요청이 오면 joinForm.html을 띄어서 회원가입을 진행할 것이다.
이때 로그인 정보를 보내면 시큐리티는 Session을 반들어준다. `SecurityContextHolder`라는 키 값에 세션정보를 저장한다.
여기서 시큐리티가 저장할 수 있는 세션에 들어가는 정보는 아래와 같다.
- Authentication 타입의 객체
- Authentication 안에 User 객체가 있어야한다.
- User 오브젝트 타입을 UserDetails 타입으로 Authentication 안에 저장되어야 한다.
- 여기서 User는 우리가 회원을 저정한 entity에 해당한다.
Authentication 객체 만들기
Authentication 객체를 만들기 위해서는 아래 2가지를 해야한다.
- UserDetails 구현하기
- UserDetailsService 구현하기
UserDetails 를 구현
package cos.security1.config.auth;
import cos.security1.model.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
// 시큐리티가 /login 주소 요청이 오면 낚아채서 로그인 진행
// 로그인 진행이 완료되면 시큐리티 session을 만들어 준다 (같은 세션인데 시큐리티 자신만의 세션을 저장, Security ContextHolder 라는 키 값에 세션 정보 저장)
//세션은 authentication 타입 객체를 가질 수 있고 authenitcation 안에 user정보가 있어야됨.
//user오브젝트 타입 =? userDetails타입 객체
@Data
public class PrincipalDetails implements UserDetails {
private User user; //콤포지션
private Map<String, Object> attributes;
//일반 로그인
public PrincipalDetails(User user) {
this.user = user;
}
//해당 User의 권한을 리턴하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//user.getRole 타입은 String이므로 그래도 반환할 수 없으니까 Collection타입으로 변환해주자
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() { //계정 만료됐니?
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
//우리 사이트에서 1년동안 회원이 로그인을 안하면 휴면 계정으로 전환한다? false로 return
// 현재시간 - 로그인 시간 => 1년을 초과하면 false
return true;
}
@Override
public String getName() {
return null;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
}
UserDetails를 구현하는 이유는 우리가 사용할 User entity를 사용할 수 있게끔 구현하는 것이다.
PrincipalDetails 안에 우리가 사용하는 User entity가 있으므로 세션에 유저 정보가 생기면,
우리는 UserDetails 타입인 PrincipalDetails로 User entity에 접근 가능하다.
UserDetailsService 구현
@Service
public class PrincipalDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
//함수 종료시 @Authentication 어노테이션이 만들어진다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("username = " + username);
User userEntity = userRepository.findByUsername(username);
if (userEntity != null) {
return new PrincipalDetails(userEntity); //userEntity를 넣어줘야 UserDetails에서 우리의 User 객체를 사용할 수 있음
}
return null;
}
}
`/login` 요청이 오면 자동으로 `UserDetailsService` 타입으로 IoC되어 있는(@Service로 인해 IoC된다) loadUserByUsername 함수가 실행된다.
loadUserByUsername이 하는 역할은 인증된 사용자 객체를 생성해주는데,
사용자가 입력한 로그인 정보(username, password) 를 PrincipalDetailService로 던져준다. 이때 password는 스프링이 인코딩하여 확인하고 username만 DB에 존재하는지 확인해서 사용자 인증 후 User 엔티티를 만들게 된다.
여기서 파라미터로 있는 username은 loginFrom.html에서 로그인시 넘기는 <input>태크의 name속성 명과 같아야한다.
loaUserByUsername함수가 종료될 때 시큐리티 세션 내부에 Authentication이 위치하게 되고 Authentication 내부에 UserDetails가 위치하게 된다.
만약에 loadUserByUsername을 오버라이딩하지 않는다면?
principalDetial에 user 생성자가 없고, `return new PrincipalDetail()` 으로 리턴이 되는 user에는 아이디=user, 패스워드=콘솔에 뜬 비번 으로 로그인이 된다
시큐리티 로그인 흐름 정리
- `/login` 요청이 오면 스프링은 IoC컨테이너에 UserDetailsService 타입(PrincipalDetailsService)을 찾는다.
- 찾으면 해당 클래스에 있는 메서드(loadUserByUsername)을 호출.
- UserDetailsService가 반환한 UserDetails(사용자 인증 객체)를 가지고 authenticationProvider에 의해 Authentication객체를 생성해서 SecurityContext 내부에 위치한다. -> 세션에 유저 정보가 저장된다
authenticatonProvider는
UserDetails를 넘겨받고 사용자 정보를 비교하고, 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.
참고 자료
'Spring > Spring Security' 카테고리의 다른 글
Spring Security를 사용해서 로그인 구현하기 - JWT(2) (0) | 2024.04.29 |
---|---|
Spring Security를 사용해서 로그인 구현하기 - JWT(1) (1) | 2024.04.28 |
Spring Security를 사용해서 로그인 구현하기 - JWT와 세션(0) (0) | 2024.04.27 |