최근에 토큰에 대해 공부하면서 토큰 기반 인증을 구현해보고 싶다는 생각이 들어 여러 가지를 찾아보다가 Spring Security에 대해 알게 되었다. 그래서 Security를 적용해보려고 며칠을 고생해봤지만 혼자서 쉽지 않았다.....
그러다가 인프런에서 강의를 하나 알게 되었고 그 강의의 내용을 포스팅하려고 한다!
Version
IDE : Intellij 2021.3.3 Ultimate
Framework : Spring Boot 2.6.8
Java Version : Java 11
Build Tool: Gradle
DBMS : Oracle
Templates : Mustache
Dependency
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Mustache
implementation 'org.springframework.boot:spring-boot-starter-mustache'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// Web
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// devtools
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// JDBC
runtimeOnly 'com.oracle.database.jdbc:ojdbc8'
https://start.spring.io/ 에서 프로젝트를 생성할 때, dependency를 추가해주면 쉽게 설정할 수 있다.
application.yml
server:
port: 8080
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
spring:
datasource:
url: jdbc:oracle:thin:@localhost:1521:XE
username: gwam
password: 12345
driver-class-name: oracle.jdbc.driver.OracleDriver
jpa:
hibernate:
ddl-auto: create #create update none
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
처음 프로젝트를 생성하면 application.properties가 resources 하위에 있을 것이다. 항상 application.properties로 사용했지만 .yml로 작성하는데 가독성이 좋다고 해서 경험해보기로 했다. .properties라는 확장자를 .yml로 바꿔주기만 하면 되므로 어려운 것은 없다.
IndexController.java
@Controller // view를 리턴하겠다.
public class IndexController {
@GetMapping({"","/"})
public String index(){
return "index";
}
}
여기서 mustache 템플릿을 사용하는데 이 상태이면 뷰 리졸버는 index.mustache를 찾기 때문에 에러가 날 것이다. 왜냐하면 index.html로 파일을 만들었기 때문에 그래서 config 패키지를 생성하고 WebMvcConfig 클래스를 만들고 configureViewResolvers()를 오버라이딩해서 .html을 찾도록 하겠다.
WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override // 뷰 리졸버가 .mustache -> .html로 받도록 변경함.
public void configureViewResolvers(ViewResolverRegistry registry) {
MustacheViewResolver resolver = new MustacheViewResolver();
resolver.setCharset("UTF-8");
resolver.setContentType("text/html; charset=UTF-8");
resolver.setPrefix("classpath:/templates/");
resolver.setSuffix(".html");
registry.viewResolver(resolver);
}
}
서버를 올리고 localhost:8080 혹은 localhost:8080/ 으로 요청을 하면 아래와 같이 index.html 화면이 뜨는 것을 볼 수 있다.
Spring Security를 써보면서 좋은 부분을 말하자면 기본적으로 제공하는 로그인 화면이 있다. ( 본인이 원하는 로그인 화면이 나타나도록 할 수 있다.) 또한, 사용자의 등급에 따른 접근 제한, 로그인이 안된 상태에서 로그인이 필요한 서비스에 접근할 때 로그인 화면으로 돌려주는 등 필자가 프로젝트를 하면서 어떻게 구현해야 할지 고민했던 부분들을 쉽게 제공해주는 좋은 프레임워크였다.
이러한 기능들을 SecurityConfig라는 클래스를 만들어서 설정해주기로 했다. (config 패키지에 하위에 생성한다.)
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록이 된다.
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception { // 스프링 시큐리티 규칙
http.csrf().disable(); // csrf 비활성화
http.authorizeRequests() // 요청에 따라 접근 권한을 설정.
.antMatchers("/user/**").authenticated() // /user/** 라는 요청이 들어오면 인증이 필요.
.antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll() // 그외는 접근 가능
.and()
.formLogin() // Form login 설정
.loginPage("/loginForm") // 커스텀 로그인 경로를 등록
.loginProcessingUrl("/login") // /login 주소가 호출되면 시큐리티가 낚아채서 대신 로그인을 진행한다. 그래서 컨트롤러에서 따로 로그인 과정을 안 만들어도 됨.
.defaultSuccessUrl("/") // 로그인이 성공하면 리다이렉트되는 기본 경로
}
}
코드 블록 안에 필요한 내용은 주석으로 설명해놓았다.
중요한 것은 WebSecurityConfigAdapter를 상속받아야 한다는 것이다. 그래야 해당 메서드를 오버라이딩해서 스프링 시큐리티의 규칙을 정의할 수 있는 것이다.
그리고 시큐리티는 기본적으로 로그인 화면을 제공해주고 커스텀한 로그인 화면을 보여주도록 설정할 수 있다고 앞에서 언급했다. and() 다음에 formLogin()을 해줌으로써 원하는 로그인 화면을 보여줄 수 있다. 그리고 이전에 프로젝트를 진행할 때는 로그인하기 위해 직접 과정을 구현했는데 반해 시큐리티는 로그인 요청이 들어오면 대신 로그인을 진행해준다.
User1.java
@Data
@Entity
public class User1 {
@Id // primary key
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private int id;
private String username;
private String password;
private String email;
private String role; //ROLE_USER, ROLE_ADMIN
@CreationTimestamp
private Timestamp createDate;
}
JPA를 활용하는 건 이번이 처음이기 사용해보기 때문에 익숙하지 않다. 그래서 JPA는 꼭 공부를 할 예정이다.
그래도 강의를 들으면서 나름대로 공부한 내용을 적도록 하겠다.
@Data는 Lombok 어노테이션이고 @Setter, @Getter 등 편리한 기능들을 담고 있다.
@Entity는 DB 테이블에 대응하는 하나의 클래스라고 한다. 즉, DB에 있는 테이블이 곧 클래스인 것이다. Mybatis를 쓸 때는 DB에 미리 테이블을 만들었지만, JPA는 클래스를 먼저 작성하고 서버를 올리니 DBMS에 테이블이 쨔란! 하고 만들어졌다.
@Id는 밑에 있는 변수가 기본 키임을 알려주는 어노테이션이다.
@GeneratedValue는 기본 키를 생성하는 방법을 선언한 것이다. 보통 @Id와 세트로 다니는 거 같다. 처음에 괄호 안을 GenerationType.IDENTITY로 했는데 계속 에러가 발생해서 .SEQUENCE로 바꿨더니 에러가 해결됐다.(Oracle을 쓰면 .SEQUENCE를 써야 한다고 하는데 JPA를 더 공부해보면 알 수 있을 거 같다.)
UserRepository.java
public interface UserRepository extends JpaRepository<User1,Integer> {
}
JpaRepository를 상속받아서 UserRepository 인터페이스를 만들었다. JpaRepository가 CRUD 함수를 가지고 있고 @Repository 어노테이션을 붙이지 않아도 자동으로 빈 등록이 된다.
(IndexController의 일부)
@PostMapping("/join")
public String join(User1 user1){
user1.setRole("ROLE_USER");
userRepository.save(user1);
return "/loginForm";
}
.......
회원 가입 요청을 하면 정상적으로 처리가 된다. 하지만 로그인을 하려고 하면 이상하게 안된다. 그 이유는 비밀번호가 암호화가 안되었기 때문이다. 회원 가입에 비밀번호를 그대로가 DB에 저장하는 게 아니라 암호화를 해서 DB에 저장해야 된다는 것이다. 그래서 SecurityConfig에서 암호화에 필요한 객체를 빈 등록할 수 있도록 해준다.
(SecurityConfig의 일부)
@Bean
public BCryptPasswordEncoder encodePwd(){
return new BCryptPasswordEncoder();
}
@Controller // view를 리턴하겠다.
@RequiredArgsConstructor
public class IndexController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
.....
@PostMapping("/join")
public String join(User1 user1){
System.out.println(user1);
user1.setRole("ROLE_USER");
String rawPwd = user1.getPassword(); // BCryptPasswordEncoder 를 SecurityConfig에서 Bean처리해놓고 여기서 씀.
String encodePwd = bCryptPasswordEncoder.encode(rawPwd);
user1.setPassword(encodePwd);
userRepository.save(user1);
return "redirect:/loginForm";
}
....
}
이렇게 암호화를 진행하고 DB에 저장해주면 아래처럼 알아볼 수 없도록 값이 저장된다.
PrincipalDetails.java
public class PrincipalDetails implements UserDetails {
private User1 user1;
public PrincipalDetails(User1 user1) {
this.user1 = user1;
}
// 해당 유저의 권한을 리턴하는 곳.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() { // user1.getRole()는 String 타입이므로
@Override //반환할 수 없으니 이렇게 만들어서 반환해줌.
public String getAuthority() {
return user1.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user1.getPassword();
}
@Override
public String getUsername() {
return user1.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
// 마지막 로그인 시간이 1년이 넘었으면 휴먼 계정으로 전환하기 위한 경우에 사용
return true;
}
}
앞서 언급했듯 시큐리티는 /login을 낚아채서 로그인을 진행시킨다. 로그인이 완료되면 시큐리티 session을 만들어주는데
이 session에 들어갈 수 있는 오브젝트 타입은 Authentication 타입의 객체이다. 그리고 Authentication 타입 객체에 들어갈 수 있는 오브젝트 타입은 UserDetails 타입의 객체이다.
즉, Security Session → 내부 (Authentication) → 내부 (UserDetails(PrincipalDetails))로 표현할 수 있다.
다음으로 Authentication 객체를 만들어보자.
PrincipalDetailsService.java
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// 시큐리티 Session(내부 Authentication(내부 UserDetails))
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
}
SecurityConfig에 loginProcessingUrl("/login")를 작성했고 login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC 되어 있는 loadUserByUsername 함수가 실행된다.
그리고 중요한 것은 loadUserByUsername() 메서드에 String username이라는 매개 변수가 있다. 이 매개 변수는 login.html에 <input> 태그의 name을 가리키는 것이다.
<login.html의 일부>
<input type="text" id="username" name="username" placeholder="유저 네임"/><br>
즉, <input>태그의 name과 loadUserByUsername()의 매개 변수의 이름이 username으로 같아야 한다. 만약에 <input> 태그의 name을 username이 아닌 다른 것으로 설정할 수 있지만 추가적으로 코드를 작성해야 하므로 웬만하면 username으로 맞추도록 하자!
public interface UserRepository extends JpaRepository<User1,Integer> {
// findBy 까지는 규칙 -> Username은 문법
// select * from user1 where username = ?
public User1 findByUsername(String username);
}
UserRepository에 findByUsername() 메서드를 만들어주고 PrincipalDetailsService()에 아래처럼 작성해주면 된다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// 시큐리티 Session(내부 Authentication(내부 UserDetails))
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User1 userEntity = userRepository.findByUsername(username);
if(userEntity != null){
return new PrincipalDetails(userEntity);
}
return null;
}
}
등급에 따른 권한 처리
.antMatchers("/user/**").authenticated() 인증만 되면 /user 라는 요청에 접근 O
.antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") /manager 라는 요청은 ADMIN이나 MANAGER만 접근 O
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')") /ADMIN이라는 요청은 ADMIN만 접근 O
SecurityConfig에 보면 등급에 따라 접근 가능한 요청을 다르게 설정했다. 지금부터 이것을 구현해보려고 한다.
구현하기 위해 SecurityConfig에 아래 어노테이션을 추가해주었다.
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // @Secured 어노테이션 활성화, preAuthorize 어노테이션 활성화
그리고 IndexController.java에 어노테이션을 작성하면 된다.
@Secured("ROLE_ADMIN") // ROLE_ADMIN을 가진 사용자가 요청을 해야함.
@GetMapping("/info")
public @ResponseBody String info(){
return "개인정보";
}
@PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") // ROLE_MANAGER 혹은 ROLE_ADMIN을 가진 사용자가 요청을 해야함.
@GetMapping("/data")
public @ResponseBody String data(){
return "데이터 정보";
}
@Secured : 하나만 제한을 걸고 싶을 때 사용한다.
@PreAuthorize : 하나 이상의 제한을 걸 때 사용한다. @Secured와 달리 괄호 안에 hasRole()이 들어간다.
이렇게 두 가지 방법으로 등급에 따른 권한 처리를 했다.
다음 포스팅에서는 OAuth2를 활용해서 로그인 처리하는 것을 할 예정이다.
전체 소스 코드
Github : https://github.com/gwamsoju/Security
'Spring > Security' 카테고리의 다른 글
[Spring Boot + Spring Security + jwt] OAuth2를 활용하여 로그인 (0) | 2022.06.23 |
---|