반응형
기본
설명
- spring-security를 활용하여 사용자 개인정보나 권한 관리를 쉽게할 수 있다.
- 권한 관련 테스트를 브라우저로 진행할 경우 매번 캐시 삭제나 시크릿 모드로 진행해야 한다.
dependencies
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
}
In-Memory Store
설명
- 사용자 정보를 메모리상에 관리하는 방식
- 사용자 추가/변경이 필요 없을 경우 미리 설정으로 지정해서 사용할 수 있다.
- 사용자 추가/변경은 코드상으로 설정한 후 빌드 및 배포를 다시 해야하기 때문에 사용자 추가/변경이 빈번하게 필요할 경우에는 부적합하다.
SecurityConfig
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user") // username 설정
.password("{noop}user123") // password 설정({noop}은 암호화를 지정하지 않는다는 키워드)
.authorities("ROLE_USER") // .roles("USER")와 동일
.and()
.withUser("admin")
.password("{noop}admin123")
.authorities("ROLE_ADMIN");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// antMaterchers 선언이 먼저된 경우가 우선순위이므로 선언 순서가 중요함
http.authorizeRequests()
.antMatchers("/api/users", "/api/products")
.access("hasRole('ROLE_USER')") // ROLE_USER 권한이 있는 사용자만 접근 가능
.antMatchers("/api/admins")
.access("hasRole('ROLE_ADMIN')") // ROLE_ADMIN 권한이 있는 사용자만 접근 가능
.antMatchers("/", "/**")
.access("permitAll") // 모든 사용자 접근 가능
.and()
.httpBasic();
}
}
JDBC Store
설명
- MySQL과 같은 데이터베이스로 사용자 정보를 관리하는 방식
- DB로 사용자를 관리하기 때문에 In-Memory Store 방식과 달리 빌드 및 배포할 필요 없어 사용자를 추가/수정하기 용이하다.
DB에 테이블 생성 및 데이터 추가
drop table if exists users;
drop table if exists authorities;
create table if not exists users
(
username varchar(50) not null primary key,
password varchar(50) not null,
enabled char(1) default '1'
);
create table if not exists authorities
(
username varchar(50) not null,
authority varchar(50) not null
);
insert into users (username, password) values ('user', 'user123');
insert into users (username, password) values ('admin', 'admin123');
insert into authorities (username, authority) values ('user', 'ROLE_USER');
insert into authorities (username, authority) values ('admin', 'ROLE_ADMIN');
dependencies
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'mysql:mysql-connector-java:8.0.24'
}
application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8&serverTimezone=Asia/Seoul&useSSL=false
spring.datasource.username=test
spring.datasource.password=test
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication()
// 반환되는 컬럼은 username, password, enabled여야 하고 조건은 username 하나여야 한다.
.usersByUsernameQuery("select username, password, enabled from users where username = ?")
.authoritiesByUsernameQuery("select username, authority from authorities where username = ?")
// 테스트를 위해 암호화 하지 않는 인코더 추가
.passwordEncoder(new NoEncodingPasswordEncoder())
.dataSource(dataSource);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// antMaterchers 선언이 먼저된 경우가 우선순위이므로 선언 순서가 중요함
http.authorizeRequests()
.antMatchers("/api/users", "/api/products")
.access("hasRole('ROLE_USER')") // ROLE_USER 권한이 있는 사용자만 접근 가능
.antMatchers("/api/admins")
.access("hasRole('ROLE_ADMIN')") // ROLE_ADMIN 권한이 있는 사용자만 접근 가능
.antMatchers("/", "/**")
.access("permitAll") // 모든 사용자 접근 가능
.and()
.httpBasic();
}
private static class NoEncodingPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
}
}
사용자 인증 커스터마이징
설명
- UserDetails, UserDetailsService를 활용하여 DB에 저장되어있는 사용자 정보를 활용해 인증하는 방식
- 아래 예제는 JPA 기반으로 구현하였으므로 JPA 기본 설정은 아래 링크 참고
테이블 생성
drop table if exists User;
create table if not exists User
(
userSeq integer auto_increment primary key,
username varchar(200) not null unique key,
password varchar(200) not null,
roles varchar(200) not null default '',
enabled char(1) not null default '1'
);
User
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
@Entity
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userSeq;
private String username;
private String password;
private String roles;
private String enabled;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(roles.split(","))
.map(role -> new SimpleGrantedAuthority(role.trim()))
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return "1".equals(enabled);
}
}
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
UserDetailsServiceImpl
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username + " not found"));
}
}
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// antMaterchers 선언이 먼저된 경우가 우선순위이므로 선언 순서가 중요함
http.authorizeRequests()
// 로그인 사용자만 접근 허용
.antMatchers("/api/products")
// .access("authenticated")
.authenticated()
// ROLE_ADMIN 권한이 있는 사용자만 접근 허용
.antMatchers("/api/users")
// .access("hasRole('ROLE_ADMIN')")
// .access("hasAuthority('ROLE_ADMIN')")
// .hasRole("ADMIN")
.hasAuthority("ROLE_ADMIN")
// ROLE_USER 권한이 있으면서 5월달인 경우에만 접근 허용
.antMatchers("/api/events")
.access("hasRole('ROLE_USER') && T(java.util.Calendar).getInstance().DAY_OF_MONTH == 5")
// 로그인/비로그인 상관 없이 모두에게 허용
// .antMatchers("/", "/**")
.anyRequest()
// .access("permitAll")
.permitAll()
// 웹 로그인 설정 (아래 설정 제거시 로그인 팝업이 뜨지 않고 403 Forbidden 페이지로 바로 이동)
.and()
.httpBasic();
}
}
사용자 데이터 추가
@SpringBootTest
public class UserTest {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserRepository userRepository;
@Transactional
@Rollback(false)
@Test
public void saveUser() {
userRepository.save(new User(null, "user", passwordEncoder.encode("user123"), "ROLE_USER", "1"));
userRepository.save(new User(null, "admin", passwordEncoder.encode("admin123"), "ROLE_USER, ROLE_ADMIN", "1"));
}
}
로그인 페이지 커스터마이징
설명
- 위 예제까지는 로그인이 필요할 경우 띄워지는 Confirm에 사용자 정보를 입력해서 로그인 처리를 하였다.
- Confirm 대신 내가 만든 페이지로 로그인 처리를 하는 방법을 설명한다.
- 아래 예제는 위의 "사용자 인증 커스터마이징" 과정을 기반으로 설명하므로 해당 과정을 참고한다.
- spring-security에서 로그인 페이지는 CSRF 공격을 막기 위해 form 안에 _csrf 이름을 갖는 hidden input을 자동으로 관리해주어 편리하다. (사용자 정의 로그인 페이지 접근시 개발자도구로 소스코드를 열어보면 자동으로 hidden input이 추가된 것을 확인할 수 있다.)
dependencies
- 로그인 페이지 개발을 위해 thymeleaf를 사용했으므로 의존성 추가가 필요하다.
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}
home.html
- 경로 : ~/src/main/resources/templates/home.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Home</title>
</head>
<body>
Hello World
</body>
</html>
login.html
- 경로 : ~/src/main/resources/templates/login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="POST" th:action="@{/login}" id="loginForm">
<label for="username">Username: </label>
<input type="text" name="username" id="username"/><br/>
<label for="password">Password: </label>
<input type="password" name="password" id="password"/><br/>
<input type="submit" value="Login"/>
</form>
</body>
</html>
logout.html
- 경로 : ~/src/main/resources/templates/logout.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Logout</title>
</head>
<body>
<h1>Logout</h1>
<form method="POST" th:action="@{/logout}">
<input type="submit" value="Logout"/>
</form>
</body>
</html>
WebMvcConfig
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
registry.addViewController("/login");
registry.addViewController("/logout");
}
}
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/users", "/api/products")
.access("hasRole('ROLE_USER')")
.antMatchers("/", "/**")
.access("permitAll")
.and()
.formLogin()
.loginPage("/login") // 로그인 필요시 "/login" 경로로 페이지 리다이렉트
// .loginProcessingUrl("/auth") // 로그인 처리를 /auth로 변경 (default : /login)
// .defaultSuccessUrl("/api/users", true) // 로그인 처리 완료 후 이동할 페이지 지정 (default : 이전에 머물렀던 페이지)
// .usernameParameter("id") // 계정 필드명을 id로 변경 (default : username)
// .passwordParameter("pw") // 비밀번호 필드명을 pw로 변경 (default : password)
.and()
.logout()
.logoutSuccessUrl("/"); // 로그아웃 처리 완료시 "/" 경로로 페이지 리다이렉트
}
}
Rest API로 인증하기
설명
- 위 예제들은 사용자 인증을 Confirm이나 웹페이지에서 하도록 되어있다.
- 아래에서는 Rest API로 사용자 인증을 처리하는 방법을 설명한다.
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // Rest API 방식일 경우 CSRF 비활성화 처리
.authorizeRequests()
.antMatchers("/api/users", "/api/products")
.access("hasRole('ROLE_USER')")
.antMatchers("/", "/**")
.access("permitAll");
}
}
LoginController
@RequiredArgsConstructor
@RestController
public class LoginController {
private final AuthenticationManager authenticationManager;
@PostMapping("/api/v1/login")
public void login(String username, String password) {
// 아래 호출시 UserDetailsService.loadUserByUsername()를 통해 사용자 정보 조회 후 Authentication 객체 생성
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
Custom Token & Provider & Filter 예제
설명
- spring security에서 인증 처리를 위한 다양한 필터를 묶어 체인 형태로 사용하고 있다.
- 아래에서는 주요 필터들 설명과 내가 원하는 인증 방식으로 처리할 수 있는 Filter를 만들어 체인에 추가하는 예제를 설명한다.
필터 체인 예시
- @EnableWebSecurity(debug = true) 설정 후 HTTP 요청을 수행하게 되면 아래처럼 실행된 필터 목록을 확인할 수 있다.
Security filter chain: [
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CsrfFilter
LogoutFilter
CustomAuthFilter
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
]
SecurityContextPersistenceFilter
- Session에 인증 정보가 있을 경우 SecurityContextHolder에 세팅하는 필터
- 로직
- Session에 "SPRING_SECURITY_CONTEXT" 키로 저장된 인증 정보를 가져온다.
- SecurityContextHolder에 세팅한다.
BasicAuthenticationFilter
- httpBasic() 설정시 "Authorization: Basic {username:password}" 값으로 인증 처리를 수행하는데 이 때 실행되는 필터
- 로직
- authenticationConverter를 통해 Authorization 값을 복호화 하여 UsernamePasswordAuthenticationToken 객체로 만든다.
- authenticationManager를 통해 인증 처리를 수행한다.
- userDetailsService를 통해 사용자 정보 조회 및 체크
- 인증 성공시 SecurityContextHolder에 인증 정보를 저장한다.
- 인증 실패시 authenticationEntryPoint를 통해 실패 후처리를 진행한다.
- 별도 지정한 authenticationEntryPoint가 없으면 BasicAuthenticationEntryPoint로 처리 진행
- BasicAuthenticationEntryPoint는 헤더에 'WWW-Authenticate=Basic realm="Realm"' 값을 넣고, UNAUTHORIZED 응답을 내려주는 처리를 수행
AnonymousAuthenticationFilter
- 인증 정보가 없을 경우 "anonymousUser" 라는 username의 인증 정보를 SecurityContextHolder에 세팅하는 필터
CustomAuthToken
- Token은 인증 정보를 담는 역할을 수행한다.
public class CustomAuthToken extends AbstractAuthenticationToken {
private final String principal;
private final String credentials;
public CustomAuthToken(Collection<? extends GrantedAuthority> authorities, String principal, String credentials) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
@Override
public Object getCredentials() {
return credentials;
}
}
CustomAuthProvider
- Provider는 인증 정보가 올바른지 검증하고 인증 정보 상세를 만들어 반환하는 역할을 수행한다.
@RequiredArgsConstructor
public class CustomAuthProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public boolean supports(Class<?> authentication) {
return CustomAuthToken.class.isAssignableFrom(authentication);
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
UserDetails userDetails = Optional.ofNullable(userDetailsService.loadUserByUsername(username))
.filter(it -> passwordEncoder.matches(password, it.getPassword()))
.orElseThrow(() -> new BadCredentialsException("Bad credentials"));
CustomAuthToken customAuthToken = new CustomAuthToken(userDetails.getAuthorities(), username, password);
customAuthToken.setDetails(userDetails);
customAuthToken.setAuthenticated(true);
return customAuthToken;
}
}
CustomAuthFilter
- Filter는 요청 정보를 Provider에 전달하여 인증 처리를 수행하고 반환된 인증 정보를 SecurityContextHolder에 세팅하는 역할을 수행한다.
@RequiredArgsConstructor
public class CustomAuthFilter extends OncePerRequestFilter {
private final AuthenticationManager authenticationManager;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("custom-token"); // "custom-token: user:user123" 으로 요청하여 테스트
if (token != null) {
String[] tokenParts = token.split(":");
String username = tokenParts[0];
String password = tokenParts[1];
CustomAuthToken customAuthToken = new CustomAuthToken(null, username, password);
Authentication authentication = authenticationManager.authenticate(customAuthToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity(debug = true) // 필터 체인, 요청 정보 등을 로그로 확인할 수 있도록 설정
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
PasswordEncoder passwordEncoder = passwordEncoder();
auth.inMemoryAuthentication()
.withUser("user") // username 설정
.password(passwordEncoder.encode("user123")) // password 설정
.authorities("ROLE_USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authenticationProvider(new CustomAuthProvider(userDetailsServiceBean(), passwordEncoder()))
.addFilterBefore(new CustomAuthFilter(authenticationManagerBean()), BasicAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/api/products").hasAuthority("ROLE_USER")
.anyRequest().permitAll()
.and().httpBasic();
}
}
인증 정보 사용하기
설명
- 인증이 정상적으로 처리된 이후 해당 인증 정보를 사용하는 여러가지 방법에 대한 예제
UserController
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserController {
@GetMapping("/api/v1/users")
public User getUserV1(Principal principal) {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) principal;
User user = (User) token.getPrincipal();
return user;
}
@GetMapping("/api/v2/users")
public User getUserV2(Authentication authentication) {
User user = (User) authentication.getPrincipal();
return user;
}
@GetMapping("/api/v3/users")
public User getUserV3(@AuthenticationPrincipal User user) {
return user;
}
@GetMapping("/api/v4/users")
public User getUserV4() {
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return user;
}
}
참고
반응형
'Development > Spring' 카테고리의 다른 글
[Spring] Thymeleaf (0) | 2021.06.26 |
---|---|
[Spring] Model Mapping (0) | 2021.06.13 |
[Spring] spring-retry (0) | 2021.05.27 |
[Spring] DataSource (0) | 2021.05.19 |
[Spring] Testcontainers (0) | 2021.04.15 |