반응형

기본

설명

  • 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

+ Recent posts