728x90

 

개요

개인 프로젝트를 진행하는 중 시큐리티 부분은 아주 중요하고 헷갈리기 때문에 정리해놓기 위해 포스팅 하기로 했다.

일단 이 부분은 이전에 작성한 jwt를 사용하지 않는 기본적인 세션방식의 시큐리티 로그인이다. 둘의 차이를 명확하게 이해하기 위해 작성한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

 

프로젝트 파일 구조

 

 

 

의존성 설치

	// security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //타임리프에서 스프링시큐리티를 쓸수있게.
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.jsoup:jsoup:1.14.3'

    //validation사용
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
  • Build.gradle 에 security 의존성을 추가해준다.
spring:
  application:
    name: testsecu

  datasource:
    url: jdbc:mysql://localhost:3306/[db이름]
    username: [자신의 아이디]
    password: [자신의 비밀번호]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

server:
  port: 8080
  • application.yml 파일에 mysql, jpa 설정을 해준다.

 

INSERT INTO roles (id, name) VALUES (1, 'ADMIN');
INSERT INTO roles (id, name) VALUES (2, 'USER');
  • 그리고 role 엔티티에 위에처럼 미리 값을 집어넣어 준다.

 

 

 

1. 엔티티 생성

  • User.java
@Entity
@Table(name = "users")
@Getter @Setter
public class User {

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

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String username;

    @Column(unique = true, nullable = false)
    private String usernick;

    @Column(name = "registrationDate", nullable = false)
    private LocalDateTime registrationDate  = LocalDateTime.now();

    private String filename;
    private String filepath;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
}
  • 일단 나는 이메일과 비밀번호로 로그인을 할 것이다.
  • 이메일(아이디), 비밀번호, 사용자이름, 닉네임, 가입일자는 필수고 나중에 사진 등록을 위한 파일경로,파일이름 칼럼도 추가해줬다.

 

  • Role.java
@Entity
@Table(name = "roles")
@Getter @Setter
public class Role {

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

    @Column(nullable = false)
    private String name;
}

 

 

 

2. DTO 설정

  • UserDto.java
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {

    private Long id;
    private String email;
    private String password;
    private String passwordCheck;
    private String username;
    private String usernick;

    public static UserDto of(User user) {
        UserDto userDto = new UserDto();
        userDto.setId(user.getId());
        userDto.setEmail(user.getEmail());
        userDto.setPassword(user.getPassword());
        userDto.setUsername(user.getUsername());
        userDto.setUsernick(user.getUsernick());
        return userDto;
    }
}
  • User 엔티티에서 회원가입에 필요한 DTO를 하나 만들어서 코드를 더욱 깔끔하게 구현했다.

 

 

3. Repository 설정

  • UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
    Optional<User> findByUsername(String username);

    boolean existsByUsernick(String userName);
    boolean existsByEmail(String email);
}
  • 이메일로 로그인을 할것이기 때문에 findByEmail이라는 메서드가 필요하고,
  • findByUsername은 관리자가 회원가입할 때 역할을 분리하기 위한 메서드이다.

 

  • RoleRepository.java
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

    Role findByName(String name);
}

 

 

4. Service 설정

  • UserService.java
@Service
public interface UserService {

    void signUp(UserDto userDto);

    Optional<User> findByEmail(String email);

    void deleteUser(String email);

}
  • 원래의 나라면 Service는 Class로 만들었을 테지만, 팀원 중 한명이 유지보수에 좋다고 해서 분리해보겠다.

 

  • UserServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    @Transactional
    public void signUp(UserDto userDto) {
        if (!userDto.getPassword().equals(userDto.getPasswordCheck())) {
            throw new RuntimeException("비밀번호가 다릅니다.");
        }
        if (userRepository.existsByEmail(userDto.getEmail())) {
            throw new RuntimeException("이메일이 존재합니다.");
        }
        if (userRepository.existsByUsernick(userDto.getUsernick())) {
            throw new RuntimeException("닉네임이 존재합니다.");
        }

        Role role = roleRepository.findByName(userDto.getUsername().equals("admin") ? "ADMIN" : "USER");

        User user = new User();
        user.setRoles(Collections.singleton(role));
        user.setEmail(userDto.getEmail());
        user.setUsernick(userDto.getUsernick());
        user.setUsername(userDto.getUsername());
        user.setPassword(passwordEncoder.encode(userDto.getPassword()));

        userRepository.save(user);
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    @Override
    public void deleteUser(String email) {
        Optional<User> emailOptional = userRepository.findByEmail(email);
        if (emailOptional.isPresent()) {
            userRepository.delete(emailOptional.get());
        } else {
            throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
        }
    }
}
  • signUp 메서드를 보면 username이 admin이면 Role을 ADMIN으로 주고 그게 아니면 USER로 주는 것으로 진행했다.

 

 

5. 컨트롤러

  • Usercontroller.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

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

//    @PostMapping("/login")
//    public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
//        try {
//            UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
//            Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
//            SecurityContextHolder.getContext().setAuthentication(authentication);
//        } catch (Exception e) {
//            redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
//        }
//    }

    // 일반 로그인 회원의 정보 가져오기
    @GetMapping("/home")
    public String index(Model model, Authentication authentication) {

        String username = authentication.getName();
        Optional<User> userOptional = userServiceimpl.findByEmail(username);
        if (userOptional.isPresent()) {
            model.addAttribute("user", userOptional.get());
            return "user/home"; // user/home.html 템플릿으로 이동
        }
        return "redirect:/api/users/login";
    }
}
  • userDto로부터 회원가입에 필요한 정보를 받아서 회원가입을 한다.
  • 여기서 애를 먹었는데, @Postmapping("/login")에서 로그인이 들어오면 그 정보를 가지고 있었는데 저부분 때문에 자꾸 로그인이 안됐다.
  • @Postmapping("/login")  메소드에서 CustomUserDetailsService를 통해 사용자를 로드하고 Authentication을 설정하려고 하지만, 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있다.
  • Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
  • 로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하는 것이 좋다.

 

 

 

6. UserDetails

  • CustomUserDetails.java
public class CustomUserDetails implements UserDetails {

    private final String email;
    private final String password;
    private final Collection<? extends GrantedAuthority> authorities;

    public CustomUserDetails(String email, String password, Set<Role> roles) {
        this.email = email;
        this.password = password;
        this.authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • spring Security의 UserDetails 인터페이스를 상속하여 정의한다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
  • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.(Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.

 

7. UserDetailsService

  • CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("로그인이 들어왓나?");
        log.info("조회할 이메일: {}", username);
        Optional<User> userOptional = userRepository.findByEmail(username);
        if (userOptional.isEmpty()) {
            log.info("사용자가 없다");
            throw new UsernameNotFoundException("사용자가 존재하지 않습니다: " + username);
        }

        User foundUser = userOptional.get();
        Set<Role> roles = foundUser.getRoles();

        return new CustomUserDetails(
                foundUser.getEmail(),
                foundUser.getPassword(),
                roles
        );
    }
}
  • 사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
  • 만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
  • Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다.
  • CustomUserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
  • Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
  • 즉, loadUserByUsername 메서드는 로그인을 진행할 때 주어진 사용자 이름(email, 로그인 진행 시 아이디)을 기반으로 사용자의 세부 정보를 검색하고 반환하는 역할을 한다.
  • jwt 방식을 사용한다면 이 방식은 필요없다.

 

7.  SecurityConfig

  • SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login") // 로그인 페이지의 경로
                        .loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
//                        .usernameParameter("email") // 변경된 필드명
//                        .passwordParameter("password")
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

 

 

 

 

9. 회원가입 폼

  • singupform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>회원가입 페이지</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #f4f4f4;
        }

        h1 {
            color: #333;
        }

        form {
            background-color: #fff;
            padding: 20px;
            border-radius: 5px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }

        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }

        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }

        button {
            width: 100%;
            padding: 10px;
            background-color: #5cb85c;
            border: none;
            border-radius: 5px;
            color: #fff;
            font-weight: bold;
            cursor: pointer;
        }

        button:hover {
            background-color: #4cae4c;
        }

        .error {
            color: red;
            font-size: 0.9em;
        }
    </style>
</head>
<body>
<form action="/api/users/signup" method="post">
    <h1>회원가입 페이지</h1>
    <hr/>

    <label for="email">Email</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>

    <label for="username">Username</label>
    <input type="text" id="username" name="username" placeholder="Username" required/>

    <label for="usernick">User Nickname</label>
    <input type="text" id="usernick" name="usernick" placeholder="User Nickname" required/>

    <label for="password">Password</label>
    <input type="password" id="password" name="password" placeholder="Password" required/>

    <label for="passwordCheck">Confirm Password</label>
    <input type="password" id="passwordCheck" name="passwordCheck" placeholder="Confirm Password" required/>

    <button type="submit">회원가입</button>
</form>
</body>
</html>

 

 

 

10. 로그인 폼

  • singupform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
  • 로그인 폼의 action URL은  "/login"으로 설정되어야 한다.
  • SecurityConfig에서 설정한 loginProcessingUrl과 일치해야 한다.

 

여기까지 하면 시큐리티 세션을 이용한 로그인 로그아웃이 가능하다. 


겪은 문제에 대해서 정리...

 

트러블 슈팅 문제

 

문제 1.

기존에 나는 이메일로 로그인을 할것이라서 loginform.html 로그인 폼에서 name="email"과 name="password"가 필드 이름으로 설정하였다.

하지만 Spring Security는 기본적으로 username과 password라는 필드명을 기대하기 때문에 로그인 폼의 필드 이름을 수정하거나, SecurityConfig에서 필드명을 커스터마이즈해야 한다. 이거 때문에 애 좀 먹었다...

 

해결 1.

<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>

가장 간단한 해결책은 로그인 폼에서 기본 필드명을 사용하는 것이다.

Spring Security는 기본적으로 username과 password라는 필드명을 기대하기 때문에 저렇게 바꾼다.

 

 

해결 2.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login")
                        .loginProcessingUrl("/login")
                        .usernameParameter("email") // 변경된 필드명
                        .passwordParameter("password")
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

SecurityConfig에서 필드명 커스터마이즈를 하는 것이다. 필드명을 커스터마이즈해야 하는 경우, SecurityConfig에서 설정을 추가로 조정해야 하면 된다.  

.usernameParameter("email") // 변경된 필드명
.passwordParameter("password")
위 두개를 넣으면 해결 된다. 하지만 첫 번째 방법이 편하니깐....ㅎㅎ

 

 

문제 2. @PoastMapping("login") 의 충돌 

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

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

//    @PostMapping("/login")
//    public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
//        try {
//            UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
//            Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
//            SecurityContextHolder.getContext().setAuthentication(authentication);
//        } catch (Exception e) {
//            redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
//        }
//    }

    @GetMapping("/home")
    public String index(Model model) {
        model.addAttribute("user", new UserDto());
        return "/home";
    }

 

해결1.

  • 위에서 spring Security의 UserDetails 인터페이스를 상속하여 정의한 CustomUserDetails 클래스가 있을 것이다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
  • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 된다!!!!!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
    (Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.

 

결론.

  • 로그인을 진행하고 @Postmapping("/login")  메소드를 사용해서 로그인을 진행할려고 했는데 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있었다.
  • Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
  • 로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하니깐 오류는 해결되었다.

 

 

 

 

다음으로는 Oauth2 로그인 방식에 대해 쓰겠다.

728x90

+ Recent posts