프로젝트 개요

  • User는 회원 가입을 하고 로그인을 할 수 있다.
  • User의 권한을 가진 사람만 캐릭터를 만들 수 있다.
  • 캐릭터는 여러 개 생성 가능하다.
  • Guest의 권한을 가진 사람은 /user과 /character에 접근이 불가능하다.
위의 요구사항을 토대로 만들겠습니다.
기본 개념들에 대한 설명은 생략하고, Security에 관한 내용만 설명하겠습니다.

1. User

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    @OneToMany(mappedBy = "user")
    public List<Character> characters = new ArrayList<>();

    @Builder
    public User(String email, String password){
        this.email=email;
        this.password=password;
        this.role=Role.ROLE_USER;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        ArrayList<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
        simpleGrantedAuthorities.add(new SimpleGrantedAuthority(role.name()));
        return simpleGrantedAuthorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}
  • getAuthoriteies(): User가 가진 권한을 반환하는 역할을 합니다. Security에서 권한을 확인할 때 사용됩니다.
  • Security에서 UserDetails를 활용하여 인증을 진행하므로 상속받습니다.

2. Role

@RequiredArgsConstructor
public enum Role {

    ROLE_USER("유저"),
    ROLE_GUEST("게스트");

    private final String key;
}

3. Character

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Character {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "character_name")
    private String name;

    @JoinColumn(name = "user_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;


    private void setUser(User user){
        this.user=user;
    }

    private void addCharacter(){
        user.getCharacters().add(this);
    }

    public void update(String name){
        this.name=name;
    }

    public static Character createCharacter(String name, User user){
        Character character = new Character();
        character.setUser(user);
        character.addCharacter();
        character.name=name;
        return character;
    }
}

4. UserRepository

public interface UserRepository extends JpaRepository<User,Long> {

    @Query("select u from User u where u.email= :email")
    Optional<User> findByEmail(@Param("email")String email);
}

5. CharacterRepository

public interface CharacterRepository extends JpaRepository<Character, Long> {
}

6. UserServiceImpl (예제의 간단화를 위해 CharacterService 로직을 UserServiceImpl에 넣었습니다.)

@Service
@RequiredArgsConstructor
@Transactional
public class UserServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final CharacterRepository characterRepository;
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email).orElseThrow(
                () -> {
                    throw new UserNotFound("없는 유저입니다.");
                }
        );
    }

    public User signup(String email, String password){
        userRepository.findByEmail(email).ifPresent(
                a-> {
                    throw new IllegalStateException("이미 가입된 이메일입니다.");
                }
        );
        User user = (User)User.builder()
                .email(email)
                .password(passwordEncoder.encode(password))
                .build();
        return userRepository.save(user);
    }

    public Character createCharacter(String name, String email){
        User user = (User) loadUserByUsername(email);
        Character character = Character.createCharacter(name, user);
        return characterRepository.save(character);
    }

    public void updateCharacter(Long id, String name){
        Character character = characterRepository.findById(id).get();
        character.update(name);
    }
}
  • Security에서는 UserDetailsService를 이용하여 인증을 진행하므로 상속받아 loadUserByUsername을 구현합니다. 여기서 username은 저희가 만든 User에서 email이라고 생각하면 되겠습니다.

7. SecuritybasicApplication 수정

@SpringBootApplication
public class SecuritybasicApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecuritybasicApplication.class, args);
    }


	// 비밀번호를 암호화 하는 곳에 쓰일 객체를 bean로 등록합니다.
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

}

8. CustomAuthenticationFilter

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(request.getParameter("username"), request.getParameter("password"));
        setDetails(request,token);
        return this.getAuthenticationManager().authenticate(token);
    }
}
  • UsernamePasswordAuthenticationFilter를 상속받아 구현합니다. HttpServletRequest로 들어온 파라미터를 기반으로 UsernamePasswordAuthenticationToken을 만듭니다.

9. CustomAuthenticationProvider

@RequiredArgsConstructor
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final PasswordEncoder passwordEncoder;
    private final UserServiceImpl userService;
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;

        String email = (String) token.getName();
        String password = (String) token.getCredentials();

        User user = (User) userService.loadUserByUsername(email);

        if(!passwordEncoder.matches(password,user.getPassword())){
            throw new BadCredentialsException("비밀 번호가 틀렸습니다.");
        }

        return new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword(),user.getAuthorities());

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}
  • AuthenticationProvider를 상속받아 구현합니다. 해당 객체에서 User인증을 진행합니다. 여기서 인증이란 DB에 저장된 email과 password과 로그인 시도를 한 것을 비교합니다.

10. CustomLoginSuccessHandler

public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        SecurityContextHolder.getContext().setAuthentication(authentication);
        response.sendRedirect("/success");
    }
}
  • SavedRequestAwareAuthenticationSuccessHandler를 상속받아 인증을 성공했다면 Authentication에 인증된 결과를 저장합니다. 그 후 /success로 redirect를 진행합니다.

11. CustomAccessDeniedHandler

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/notAccess");
    }
}
  • 사용하는 접근자의 권한이 접근하고자 하는 자원이 요구하는 권한과 다를 때 /notAccess로 보내 자원에게 접근하는 것을 차단합니다. 

12. SecurityConfig (주석으로 설명을 넣어놓겠습니다.)

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    private final CustomAuthenticationProvider customAuthenticationProvider;

    // 정적 컨텐츠가 있다면 작성합니다.
    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
        /* ex
        web.ignoring().antMatchers("/~~"); 경로를 적어줍니다.
         */
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.cors().disable()
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/login","/signup","/","/notAccess").permitAll() //해당 url에는 모두 접근가능
                .antMatchers("/user","/character").hasRole("USER") // ROLE_USER를 가진 사용자만 접근 가능
                .antMatchers("/guest","/success").authenticated() // 어떠한 권한을 한 가지만 가지고 있다면 접근 가능
                .and()
                .exceptionHandling() // 예외를 다루겠다는 뜻
                .accessDeniedHandler(new CustomAccessDeniedHandler()) //위에서 만든 handler를 넣어줍니다.
                .authenticationEntryPoint((request, response, authException) -> response.sendRedirect("/notAccess"))// 람다로 표현한 것입니다. 이것은 권한을 아무것도 가지지 않은채 
                .and()                                                                                              // 접근했을 경우 /notAccess로 접근을 차단합니다.          
                .logout()
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .and()
                .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);// 만든 Filter를 넣어줍니다.
    }
    // AuthenticationManager, CustomLoginSuccessHandler를 주입하고 bean으로 등록해줍니다.
    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        customAuthenticationFilter.setAuthenticationSuccessHandler(new CustomLoginSuccessHandler());
        return customAuthenticationFilter;
    }
	
    // 저희가 만든 Provider를 사용하도록 넣어줍니다.
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customAuthenticationProvider);
    }
}

13. UserController

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserServiceImpl userService;

    @GetMapping("/")
    public String index(){
        return "Welcome Home";
    }

    @PostMapping("/signup")
    public String signup(@RequestBody UserDto userDto){
        User success = userService.signup(userDto.getEmail(), userDto.getPassword());
        return "signup Success";
    }

    @PostMapping("/character")
    public String create(@RequestParam(name = "email") String email,
                         @RequestParam(name = "name") String name){
        Character character = userService.createCharacter(name, email);
        return "ok";
    }

    @PatchMapping("/character")
    public String update(@RequestParam(name = "id") Long id,
                         @RequestParam(name = "name") String name){
        userService.updateCharacter(id,name);
        return "ok";
    }

    @GetMapping("/user")
    public String user(){
        return "user";
    }

    @GetMapping("/notAccess")
    public String access(){
        return "notAccess";
    }
    @GetMapping("/guest")
    public String guest(){
        return "guest";
    }
    @GetMapping("/success")
    public String success(){
        return "success";
    }
}
  • Sesstion을 따로 사용하지 않았기 때문에 @RequestParam을 이용해서 사용자의 email을 받아야 합니다.

14. Postman test (1)

  • 로그인을 하지 않은 채 authenticated()와 USER권한이 있어야 되는 곳을 갔을 때 /notAccess로 이동하고 결과를 받는 것을 볼 수 있습니다.

  • 회원가입과 로그인을 했을 때 /success로 redirect 한 것을 볼 수 있습니다. (login 할 때는 parameter로 email과 password를 넘겨줘야 합니다.)

  • User를 생성할 때 권한을 ROLE_USER로 만들었으므로 /user에 접근할 수 있는 것을 볼 수 있습니다.

15. Postman test (2)

  • User를 생성할 때 권한을 ROLE_USER를 줬던 것을 ROLE_GUEST로 변경해보고 위에서 했던 것을 그대로 진행해보면 권한이 GUEST이므로 /user에서는 권한이 옳지 않아 접근이 불가능한 것을 알 수 있습니다.

  • GUEST의 권한을 가진 유저가 로그인을 했습니다.

  • 권한이 USER가 아닌 GUEST이므로 접근이 거부된 것을 볼 수 있습니다.

지금까지 권한에 대한 Security는 모두 만들었습니다. 하지만 여기서 문제 접은 인가입니다. 현재까지 만든 것은 User의 권한만 있다면 다른 사람의 Character를 수정할 수 도 있습니다. 예시를 보여드리겠습니다. User를 생성할 때 권한은 다시 ROLE_USER로 변경합니다.


16. Postman test (3)

  • asdf의 user와 qwer의 유저가 있습니다. asdf의 유저가 Character aa를 만들었습니다. 그렇다면 asdf유저 만이 aa에 접근할 수 있을까요?? 정답은 아닙니다. 현재 인증을 진행하고 권한을 확인하는 로직만 만들었습니다. 밑에 예시를 보여드리겠습니다.
  • asdf User 회원가입

  • qwer User 회원가입

  • asdf User 로그인

  • asdf User가 캐릭터의 이름이 aa를 생성합니다.

  • qwer User가 로그인합니다.

  • qwer User가 asdf User가 만든 캐릭터의 이름을 수정해버렸습니다.

 

위의 문제점을 해결하기 위해서 인가에 대한 설정이 따로 필요합니다. Filter부분에 로직을 좀 더 추가할 수도 있지만 저희는 Interceptor를 만들어서 해당 부분을 처리하겠습니다. 감사합니다.