안녕하세요! 이번 포스팅에서는 Spring Security에 대해 알아보겠습니다. 🤗 Spring Security는 정말 자주 사용하는 기능이자 그만큼 중요하기 때문에 전체적인 내용을 복습할 겸 잊어버리지 않기 위해 포스팅에 기록해두려고 합니다.
전체 코드는 Github에서 확인이 가능합니다.
✍️ 개념 정리
1. Spring Security란?
Spring Security란 보안 솔루션을 제공해주는 Spring 기반의 스프링 하위 프레임워크입니다. Spring Security에서 제공해주는 보안 솔루션을 사용하면 개발자가 보안 관련 로직을 짤 필요가 없어 굉장히 간편합니다. Spring Security를 이해하기 위해서는 인증과 권한에 대한 뜻을 알아야 합니다.
2. 인증과 권한
인증(Authentication)과 권한(Authorization)은 단어가 비슷해서 뜻이 같다고 오해할 수 있는데, 인증은 자신이 '누구'라고 주장하는 사람을 정말 '누구'가 맞는지 확인하는 작업, 권한은 특정 부분에 접근할 수 있는지에 대한 여부를 확인하는 작업을 의미합니다. 인증과 권한을 확인하기 위해 직접 로직을 짠다면 상당한 시간과 노력이 필요하지만, Spring Security에서 제공하는 인증 메커니즘과 권한 부여 기능을 사용한다면 인증/인가 처리를 쉽게 처리할 수 있습니다.
3. Spring Security를 사용하는 이유
- 모든 URL에 대한 인증을 요구
- 로그인 폼을 생성, 로그아웃 처리
- CSRF 공격을 방어
- Session Fixation 공격 방어
- 요청 헤더 보안
- HTTP Strict Transport Security (HSTS)
- X-Content-Type-Options
- 캐시 컨트롤 (정적 리소스는 캐싱)
- X-XSS-Protection
- 클릭 재킹 방지를위한 X-Frame-Options
- 서블릿 API 메소드와 통합
🏃♂️ 사전 준비
무엇보다 Spring Security를 이해하기 가장 좋은 방법은 예제를 진행하면서 직접 돌아가는 것을 확인하는 것이라고 생각하여 간단한 예제를 준비했습니다.
1. 프로젝트 설명
이번에 만든 프로젝트는 간단한 로그인, 회원가입 프로젝트입니다. 총 페이지의 수는 4가지이고, 각각의 기능과 특징을 가지고 있습니다.
- 로그인 페이지
- 로그인을 할 수 있는 페이지
- 누구나 접근이 가능
- 로그아웃을 하게 되면 보이는 페이지
- 회원가입 페이지
- 회원가입을 할 수 있는 페이지
- 누구나 접근 가능
- 유저 전용 페이지
- 로그인 성공하면 이동하는 페이지
- 유저, 관리자만 접근 가능
- 로그아웃 기능
- 관리자 전용 페이지
- 관리자만 접근 가능
- 로그아웃 기능
권한의 종류는 유저, 관리자로 이루어져 있으며, 회원가입 페이지에서 설정이 가능합니다.
2. 의존성
(SpringBoot에서 프로젝트를 만들 때 기본적으로 추가할 수 있는 의존성을 의미합니다)
- Web
- Lombok
- Thymeleaf
- JPA
- H2
💻 구현 - 로그인
1. 의존성 추가
build.gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
}
spring-boot-starter-security와 thymeleaf-extras-springsecurity5를 추가해줍니다.
- spring-boot-starter-security : 스프링 시큐리티를 사용하기 위해 추가
- thymeleaf-extras-springsecurity5 : 뷰 단에서 현재 로그인된 사용자의 정보를 가져오기 위해 추가
2. Config 파일 작성
Spring Security에서 관련된 설정을 하기 위해 필요한 config 파일을 만들어보도록 하겠습니다. Config파일은 WebSecurityConfigurerAdapter를 상속받아 구현하면 됩니다.
WebSecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity // 1
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { // 2
private final UserService userService; // 3
@Override
public void configure(WebSecurity web) { // 4
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception { // 5
http
.authorizeRequests() // 6
.antMatchers("/login", "/signup", "/user").permitAll() // 누구나 접근 허용
.antMatchers("/").hasRole("USER") // USER, ADMIN만 접근 가능
.antMatchers("/admin").hasRole("ADMIN") // ADMIN만 접근 가능
.anyRequest().authenticated() // 나머지 요청들은 권한의 종류에 상관 없이 권한이 있어야 접근 가능
.and()
.formLogin() // 7
.loginPage("/login") // 로그인 페이지 링크
.defaultSuccessUrl("/") // 로그인 성공 후 리다이렉트 주소
.and()
.logout() // 8
.logoutSuccessUrl("/login") // 로그아웃 성공시 리다이렉트 주소
.invalidateHttpSession(true) // 세션 날리기
;
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception { // 9
auth.userDetailsService(userService)
// 해당 서비스(userService)에서는 UserDetailsService를 implements해서
// loadUserByUsername() 구현해야함 (서비스 참고)
.passwordEncoder(new BCryptPasswordEncoder());
}
}
- Spring Security를 활성화한다는 의미의 어노테이션입니다.
- WebSecurityConfigurerAdapter는 Spring Security의 설정파일로서의 역할을 하기 위해 상속해야 하는 클래스입니다.
- 후에 사용할 유저 정보를 가져올 클래스입니다. 아직 만들어지지 않았습니다.
WebSecurityConfigurerAdapter
를 상속받으면 오버라이드할 수 있습니다. 인증을 무시할 경로들을 설정해놓을 수 있습니다.- static 하위 폴더 (css, js, img)는 무조건 접근이 가능해야하기 때문에 인증을 무시해야합니다.
WebSecurityConfigurerAdapter
를 상속받으면 오버라이드할 수 있습니다.- http 관련 인증 설정이 가능합니다
- 접근에 대한 인증 설정이 가능합니다.
anyMatchers
를 통해 경로 설정과 권한 설정이 가능합니다.
permitAll()
: 누구나 접근이 가능hasRole()
: 특정 권한이 있는 사람만 접근 가능authenticated()
: 권한이 있으면 무조건 접근 가능anyRequest
는anyMatchers
에서 설정하지 않은 나머지 경로를 의미합니다.
- 로그인에 관한 설정을 의미합니다.
loginPage()
: 로그인 페이지 링크 설정defaultSuccessUrl()
: 로그인 성공 후 리다이렉트할 주소
- 로그아웃에 관한 설정을 의미합니다.
logoutSccessUrl()
: 로그아웃 성공 후 리다이렉트할 주소invalidateHttpSession()
: 로그아웃 이후 세션 전체 삭제 여부
- 로그인할 때 필요한 정보를 가져오는 곳입니다.
- 유저 정보를 가져오는 서비스를
userService
(아직 만들어지지 않음)으로 지정합니다. - 패스워드 인코더는 아까 빈으로 등록해놓은
passwordEncoder()
를 사용합니다. (BCrypt)
- 유저 정보를 가져오는 서비스를
이 외에도 더 많은 속성들이 있지만, 대표적으로 많이 쓰이는 설정들 위주로 정리했습니다. 이제 UserService를 만들기 위해 필요한 User와 User 정보를 가져올 UserRepository를 만들어보도록 하겠습니다.
3. User Entity 작성
User 엔티티는 UserDetails를 상속받아서 구현합니다. UserDetails에서 필수로 구현해야 하는 메소드는 아래와 같습니다.
메소드 이름 | 설명 |
getAuthorities() | 사용자의 권한을 콜렉션 형태로 반환 (클래스 자료형은 GrantedAuthority를 구현해야함) |
getUsername() | 사용자의 id를 반환 (id는 unique한 값이여야함) |
getPassword() | 사용자의 password를 반환 |
isAccountNonExpired() | 계정 만료 여부 반환 (true = 만료되지 않음을 의미) |
isAccountNonLocked() | 계정 잠금 여부 반환 (true = 잠금되지 않음을 의미) |
isCredentialsNonExpired() | 패스워드 만료 여부 반환 (true = 만료되지 않음을 의미) |
isEnabled() | 계정 사용 가능 여부 반환 (true = 사용 가능을 의미) |
UserInfo.java
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
public class UserInfo implements UserDetails {
@Id
@Column(name = "code")
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long code;
@Column(name = "email", unique = true)
private String email;
@Column(name = "password")
private String password;
@Column(name = "auth")
private String auth;
@Builder
public UserInfo(String email, String password, String auth) {
this.email = email;
this.password = password;
this.auth = auth;
}
// 사용자의 권한을 콜렉션 형태로 반환
// 단, 클래스 자료형은 GrantedAuthority를 구현해야함
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> roles = new HashSet<>();
for (String role : auth.split(",")) {
roles.add(new SimpleGrantedAuthority(role));
}
return roles;
}
// 사용자의 id를 반환 (unique한 값)
@Override
public String getUsername() {
return email;
}
// 사용자의 password를 반환
@Override
public String getPassword() {
return password;
}
// 계정 만료 여부 반환
@Override
public boolean isAccountNonExpired() {
// 만료되었는지 확인하는 로직
return true; // true -> 만료되지 않았음
}
// 계정 잠금 여부 반환
@Override
public boolean isAccountNonLocked() {
// 계정 잠금되었는지 확인하는 로직
return true; // true -> 잠금되지 않았음
}
// 패스워드의 만료 여부 반환
@Override
public boolean isCredentialsNonExpired() {
// 패스워드가 만료되었는지 확인하는 로직
return true; // true -> 만료되지 않았음
}
// 계정 사용 가능 여부 반환
@Override
public boolean isEnabled() {
// 계정이 사용 가능한지 확인하는 로직
return true; // true -> 사용 가능
}
}
여기서 추가로 설명할 부분은 getAuthorities()
인데, 이 메소드는 사용자의 권한을 콜렉션 형태로 반환해야하고, 콜렉션의 자료형은 무조건적으로 GrantedAuthority
를 구현해야합니다. 저는 권한이 중복되면 안 되기 때문에 Set<GrantedAuthority>
을 사용했습니다.
ADMIN은 관리자의 권한(ADMIN)뿐만 아니라 일반 유저(USER)의 권한도 가지고 있기 때문에, ADMIN의 auth는 "ROLE_ADMIN,ROLE_USER"와 같은 형태로 전달이 될 것이고, 쉼표(,) 기준으로 잘라서 ROLE_ADMIN과 ROLE_USER를 roles에 추가해줍니다. 아까 루트 패스("/")에 권한을 USER에게만 주었지만, ADMIN은 두 개의 권한(USER, ADMIN)을 가지고 있기 때문에 접근이 가능합니다.
이제 User 엔티티를 모두 구현했으니, 정보를 가져오기 위해 필요한 Repository를 구현해보겠습니다.
4. User Repository 작성
UserRepository.java
public interface UserRepository extends JpaRepository<UserInfo, Long> {
Optional<UserInfo> findByEmail(String email);
}
JpaRepository를 상속받아줍니다. 그리고 email를 통해 회원을 조회하기 위해 findByEmail()
를 만들어줍니다.
이제 Entity와 Repository까지 만들었으니, 아까 Config 파일에서 미리 주입해놓았던 UserService를 만들어보도록 하겠습니다.
5. User Service 작성
UserService.java
@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
/**
* Spring Security 필수 메소드 구현
*
* @param email 이메일
* @return UserDetails
* @throws UsernameNotFoundException 유저가 없을 때 예외 발생
*/
@Override // 기본적인 반환 타입은 UserDetails, UserDetails를 상속받은 UserInfo로 반환 타입 지정 (자동으로 다운 캐스팅됨)
public UserInfo loadUserByUsername(String email) throws UsernameNotFoundException { // 시큐리티에서 지정한 서비스이기 때문에 이 메소드를 필수로 구현
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException((email)));
}
}
아까 설정해놓은 서비스를 만들어주고, UserDetailService를 상속받습니다. 그 후에 필수 메소드인 loadUserByUsername()
를 구현하는데, 기본 반환 타입인 UserDetails를 UserInfo로 바꿔줍니다. UserInfo는 UserDetails을 상속받았기 때문에 자동으로 다운 캐스팅이 됩니다.
그리고 방금 만든 UesrRepository의 findByEmail()
를 사용해서 null이면 UsernameNotFoundException
예외를 발생시키고, null이 아니면 UserInfo를 반환하게 처리해줍니다. 이렇게 하면 로그인 관련 기능 구현은 모두 완료되었습니다.
다음으로 회원가입 관련 기능을 구현해보도록 하겠습니다.
💻 구현 - 회원가입
1. User DTO 작성
가장 먼저 할 일은 폼으로 받을 회원정보를 매핑 시켜줄 객체를 만드는 작업입니다.
UserInfoDto.java
@Getter
@Setter
public class UserInfoDto {
private String email;
private String password;
private String auth;
}
이메일과 비밀번호, 권한 정보를 담을 DTO를 만들어줍니다.
2. User Service 수정
UserService.java
@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
...
/**
* 회원정보 저장
*
* @param infoDto 회원정보가 들어있는 DTO
* @return 저장되는 회원의 PK
*/
public Long save(UserInfoDto infoDto) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
infoDto.setPassword(encoder.encode(infoDto.getPassword()));
return userRepository.save(UserInfo.builder()
.email(infoDto.getEmail())
.auth(infoDto.getAuth())
.password(infoDto.getPassword()).build()).getCode();
}
}
회원정보를 저장하는 메소드를 만들기 위해 UserService를 수정합니다. 입력받은 패스워드를 BCrypt로 암호화한 후에 회원을 저장해주는 메소드를 만들어줍니다.
3. Controller 작성
DTO를 만들었으니 Controller를 만들어주도록 하겠습니다.
UserController.java
@RequiredArgsConstructor
@Controller
public class UserController {
private final UserService userService;
@PostMapping("/user")
public String signup(UserInfoDto infoDto) { // 회원 추가
userService.save(infoDto);
return "redirect:/login";
}
}
/user로 POST 요청이 오면 방금 만들어둔 userService의 save()
를 호출한 후에, /login 페이지로 이동하게 해줍니다.
회원가입 로직까지 완성했습니다!
💻 구현 - 로그아웃
Spring Security 설정 파일에서 로그아웃 관련 설정을 하긴 했지만, 이 로그아웃은 POST요청에 csrf를 보내야 하기 때문에 직접 패스를 치면 404 에러가 뜹니다. 그렇기 때문에 GET 요청으로 로그아웃을 해도 로그아웃이 가능하게 구현해보겠습니다.
1. 컨트롤러 수정
UserController.java
@RequiredArgsConstructor
@Controller
public class UserController {
private final UserService userService;
...
// 추가
@GetMapping(value = "/logout")
public String logoutPage(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
}
기본으로 제공해주는 SecurityContextLogoutHandler()
의 logout()
을 사용해서 로그아웃 처리를 해주었습니다.이제 로그아웃까지 구현했으니, 나머지를 구현해보도록 하겠습니다.
💻 구현 - 나머지
하나하나에 컨트롤러에 매핑해도 되지만, Config 파일을 하나 만들어서 요청과 뷰를 연결해주도록 하겠습니다.
1. 요청 - 뷰 연결
MvcConfig.java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// 요청 - 뷰 연결
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("main");
registry.addViewController("/login").setViewName("login");
registry.addViewController("/admin").setViewName("admin");
registry.addViewController("/signup").setViewName("signup");
}
}
2. 뷰 작성
이제 해당하는 뷰를 만들어보도록하겠습니다.
login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>login</title>
</head>
<body>
<h1>Login</h1> <hr>
<img src="/img/info.jpeg" />
<form action="/login" method="POST">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
email : <input type="text" name="username"> <br>
password : <input type="password" name="password"> <br>
<button type="submit">Login</button>
</form> <br>
<a href="/signup">Go to join! →</a>
</body>
</html>
static 아래 폴더는 스프링 시큐리티 설정을 무시하는 설정이 실제로 동작하는지 확인하기 위해 이미지를 static/img에 넣어 준 후에 확인합니다. 로그인할 때에는 csrf를 보내줘야하기 때문에 hideen 타입으로 함께 보냅니다.
signup.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>sign up</title>
</head>
<body>
<h1>Sign Up</h1> <hr>
<form th:action="@{/user}" method="POST">
email : <input type="text" name="email"> <br>
password : <input type="password" name="password"> <br>
<input type="radio" name="auth" value="ROLE_ADMIN,ROLE_USER"> admin
<input type="radio" name="auth" value="ROLE_USER" checked="checked"> user <br>
<button type="submit">Join</button>
</form> <br>
<a href="/login">Go to login →</a>
</body>
</html>
main.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<title>main</title>
</head>
<body>
<h2>회원 전용 페이지</h2>
ID : <span sec:authentication="name"></span> <br>
소유 권한 : <span sec:authentication="authorities"></span> <br>
<form id="logout" action="/logout" method="POST">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="submit" value="로그아웃"/>
</form>
</body>
</html>
sec:authentication를 이용하면 권한, 이름 같이 현재 로그인된 정보를 확인할 수 있습니다. 로그아웃 역시 POST로 csrf를 붙여줍니다. (하지만 아까 GET /logout에 관한 컨트롤러 메소드를 만들어주었기 때문에 <a href="/logout">를 사용해도 무방합니다)
admin.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<meta charset="UTF-8">
<title>admin</title>
</head>
<body>
<h2>관리자 전용 페이지</h2>
ID : <span sec:authentication="name"></span> <br>
소유 권한 : <span sec:authentication="authorities"></span> <br>
<form id="logout" action="/logout" method="POST">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="submit" value="로그아웃"/>
</form>
</body>
</html>
📝 테스트
드디어 구현이 모두 끝났습니다! 이제 제대로 작동이 되는지 확인해보도록 하겠습니다.
localhost:8080으로 접속합니다.
로그인하지 않은 상태이기 때문에 자동으로 /login으로 이동하게 됩니다. 인증 무시를 걸어놓은 이미지는 제대로 보이는 것을 확인할 수 있습니다.
Go to join(/signup)을 눌러 회원가입을 해보겠습니다.
user로 선택을 하고 회원가입을 해줍니다. 그 후에 방금 가입했던 이메일과 패스워드로 가입을 해주면 됩니다.
회원가입한 데이터를 눈으로 직접 확인하는 방법을 접은글에 추가 했습니다! 만약 궁금하신 분들은 아래 접은글을 참고해주세요! 👇
회원가입이 제대로 되었는지 확인하기 위해, H2 Database를 조회하는 방법을 추가합니다. (질문 주셔서 감사합니다! 😁) 추가한 코드는 해당 커밋에서 확인할 수 있습니다.
H2 Database를 조회하기 위해서는 properties 파일에 설정을 추가해주어야합니다.
application.properties
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
H2는 웹페이지에서 확인할 수 있기 때문에, Security 설정을 무시해주어야합니다. 그러므로 아래와 같이 Security 를 무시하는 설정에서 경로를 추가해줍니다.
WebSecurityConfig.java
...
@Override
public void configure(WebSecurity web) { // static 하위 파일 목록(css, js, img) 인증 무시
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/h2-console/**"); // h2-console 추가
}
...
그 다음에 어플리케이션을 재구동하고, localhost:8080/h2-console로 접속합니다.
JDBC URL과 User Name이 사진과 같은지 확인한 후에 Connect를 해줍니다.
정상적으로 접속이 완료되면 가장 왼쪽에 USER_INFO가 있는것을 확인합니다. 그 후에 쿼리문을 작성하고 RUN을 해주면 아래와 같이 회원가입한 데이터가 나오는 것을 정상적으로 확인할 수 있습니다.
회원 전용 페이지는 들어갈 수 있지만, 관리자 페이지로 가게 되면 권한이 없기 때문에 403 Forbidden 이 뜨는 것을 확인할 수 있습니다.
로그아웃을 해주고(로그아웃 버튼을 눌러도 되고, 직접 path를 입력하셔도 됩니다),
이번에는 관리자로 회원가입을 한 후에 로그인을 해줍니다.
이번에는 권한이 USER뿐만 아니라 ADMIN도 있기 때문에, 회원 전용 페이지와 관리자 전용 페이지 모두 접근이 가능합니다. 모든 기능이 제대로 동작하는 것을 확인할 수 있습니다!
✌️ 정리
이렇게 해서 Sprint Security에 대한 포스팅이 끝났습니다! 제대로 못 다룬 부분도, 제가 잘 모르는 부분도 많아서 모든 내용을 다루지는 못한게 조금 아쉽습니다 😿 다음에 기회가 된다면 추가적인 내용에 대해 포스팅을 해보려고 합니다.
혹시 글을 읽으면서 잘못된 내용이 있으면 댓글로 알려주시면 감사하겠습니다!
읽어주셔서 감사합니다! 😊
'Backend > SpringBoot' 카테고리의 다른 글
Spring Boot Custom Annotation 만들기 (4) | 2020.07.08 |
---|---|
Spring Security Error Message 커스텀하기 (9) | 2020.06.02 |
SpringBoot API 요청 값 검증하고 Validation Exception Handing하기 (1) | 2020.04.14 |
SpringBoot의 POI을 이용해서 엑셀 파일 읽기 (9) | 2020.03.30 |
SpringBoot의 MockHttpSession을 이용해서 JUnit Mock 테스트하기 (0) | 2020.03.26 |