[ 12주차 - 1105 ]
금일 커리큘럼
├ 09:00 ~ 12:00 backend 프로그래밍 (Spring Security, rememberMe, inMemoryUserDetailsManager)
└ 13:00 ~ 18:00 backend 프로그래밍 (Spring Security 인터페이스 정리, JPA 활용 security 회원가입 심플 구현)
1. rememberMe 기능
JSESSIONID이 만료되거나 쿠키가 없을 지라도 어플리케이션이 사용자를 기억하는 기능
rememberMe: 토큰으로 접속 상태 유지 설정 (재방문 시 자동 로그인인 상태)rememberMeParameter: 로그인 폼에서 사용할 파라미터 이름 설정tokenValiditySeconds: 토큰 유효 기간 설정 (초 단위)rememberMeCookieName: 쿠키 이름 지정 (기본값 : "remember-me")
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest()
.authenticated()
)
// 기본 로그인 폼 설정
.formLogin(Customizer.withDefaults())
// 토큰으로 접속 상태 유지 설정 (기본폼에 rememberMe 체크박스 추가됨)
.rememberMe(rememberMe -> rememberMe
// 로그인 폼에서 사용할 파라미터 이름 설정
.rememberMeParameter("rememberMe")
// 쿠키 이름 지정 (기본값 : "remember-me")
// .rememberMeCookieName("customRememberMe")
// 토큰 유효 기간 설정 (초 단위)
.tokenValiditySeconds(60)
);
return http.build();
}
}
로그아웃 시 rememberMe 쿠키 삭제 설정
rememberMe에서 쿠키의 네임 기본값은 "remember-me" (소문자, 하이픈)rememberMe에서rememberMeCookieName로 쿠키이름 지정한 경우 해당 이름으로 쿠키삭제
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/hello")
// 로그아웃 시 rememberMe 쿠키 삭제 설정
.deleteCookies("JSESSIONID", "remember-me") // 기본값 쿠키 이름
);
rememberMe 동작 방식
.tokenValiditySeconds(60 * 60 * 24) : 1일 설정 시
- 사용자가 로그인 시 rememberMe 체크박스 선택 후 로그인
- 서버는 사용자를 인증하고,
remember-me쿠키를 생성하여 클라이언트에 전송 - 클라이언트는 이후 요청 시
remember-me쿠키를 함께 전송 - 서버는
remember-me쿠키를 확인하여 사용자를 자동으로 인증 - 사용자는 해당 유효기간 내 재방문하여도 자동으로 로그인된 상태 유지된다
2. inMemoryUserDetailsManager 유저 설정
메모리에 유저 정보를 저장하는 UserDetailsService 구현체
UserDetailsService인터페이스를 구현한InMemoryUserDetailsManager클래스를 빈으로 등록User.withUsername()메서드를 사용하여 유저 생성passwordEncoder를 사용하여 비밀번호 암호화roles()메서드를 사용하여 유저 역할 지정- 여러 유저를 생성하여
InMemoryUserDetailsManager에 전달 - http에서 단일 :
hasRole(), 다중 :hasAnyRole()통해서 접근 권한 설정
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/hello", "/static/**").permitAll()
.requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/admin/super").hasRole("SUPERUSER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/hello")
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 얘가 있어야 패스워드 인코딩 됨
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails user1 = User.withUsername("user1")
.password(encoder.encode("1234"))
.roles("USER")
.build();
UserDetails user2 = User.withUsername("star1431")
.password(encoder.encode("star1431"))
.roles("USER", "ADMIN")
.build();
UserDetails user3 = User.withUsername("admin")
.password(encoder.encode("1234"))
.roles("ADMIN")
.build();
UserDetails user4 = User.withUsername("super")
.password(encoder.encode("1234"))
.roles("SUPERUSER")
.build();
return new InMemoryUserDetailsManager(user1, user2, user3, user4);
}
}
현재 로그인 유저 정보 가져오는 방법
- 방법1 : SecurityContext에서 직접 인증 정보 가져오기
- 방법2 :
@AuthenticationPrincipal어노테이션 사용
@RestController
public class TestController {
// 1. SecurityContext에서 직접 인증 정보 가져오기
@GetMapping("/info")
public String info() {
String msg;
// SecurityContext에서 현재 인증 정보를 가져옴
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 인증 객체가 null이거나 인증되지 않은 상태인지 확인
if(authentication == null || !authentication.isAuthenticated()) {
msg = "로그인한 사용자 없습니다";
}
// 인증된 주체(principal) 객체를 가져옴
Object principal = authentication.getPrincipal();
// principal이 UserDetails 타입인지 확인 (일반적인 사용자 인증의 경우)
if(principal instanceof UserDetails) {
UserDetails userDetails = (UserDetails) principal;
msg = "현재 로그인 유저 : " + userDetails.getUsername();
} else {
// UserDetails가 아닌 다른 타입의 principal인 경우 (예: 익명 사용자)
msg = "현재 로그인 유저 : " + principal.toString();
}
return msg;
}
// 2. @AuthenticationPrincipal 어노테이션 사용
@GetMapping("/info_spring")
public String infoSpring(@AuthenticationPrincipal UserDetails userDetails) {
if(userDetails == null) {
return "로그인한 사용자 없습니다";
}
return "현재 로그인 유저 : " + userDetails.getUsername();
}
}
3. Spring Security 인터페이스
앞선 예제의
UserDetails,UserDetailsService... 등 인터페이스 설명
Spring Security 아키텍처 요약
사용자 로그인 요청
↓
UserDetailsService → UserDetails 반환
↓
PasswordEncoder → 비밀번호 검증
↓
Authentication 객체 생성
↓
SecurityContext에 저장
↓
Controller에서 @AuthenticationPrincipal로 접근
UserDetails (사용자 정보 객체)
사용자 정보를 나타내는 인터페이스
- 사용자명, 비밀번호, 권한, 계정 상태 등의 메서드 제공
UserDetailsService에서 반환하는 사용자 정보 타입임
public interface UserDetails {
String getUsername(); // 사용자명
String getPassword(); // 비밀번호
Collection<? extends GrantedAuthority> getAuthorities(); // 권한들
boolean isAccountNonExpired(); // 계정 만료 여부
boolean isAccountNonLocked(); // 계정 잠금 여부
boolean isCredentialsNonExpired(); // 자격증명 만료 여부
boolean isEnabled(); // 계정 활성화 여부
}
UserDetailsService (사용자 정보 서비스)
사용자 정보를 로드하는 서비스 인터페이스
InMemoryUserDetailsManager: 메모리에 사용자 정보 저장 (테스트/개발용)loadUserByUsername(String username)메서드를 통해 사용자명으로UserDetails객체 반환- 실제 운영환경에서는 데이터베이스 연동하여 구현해야함. only test 용
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
PasswordEncoder (비밀번호 암호화)
비밀번호를 안전하게 암호화하는 인터페이스
- BCrypt 해시 알고리즘을 사용하여 안전하게 비밀번호 암호화
- salt + hash를 사용해 같은 비밀번호라도 매번 다른 해시값 생성
- 평문 비밀번호 저장 시 보안 위험을 방지
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // BCrypt 해시 알고리즘 사용
}
// 예시: "1234" → "$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iYqiSfFVMLT..."
Authentication (인증 정보 객체)
현재 로그인한 사용자의 인증 정보를 담는 객체
- 현재 스레드에서 인증된 사용자의 모든 정보를 포함
SecurityContext에 저장되어 애플리케이션 전반에서 접근 가능
public interface Authentication extends Principal {
Collection<? extends GrantedAuthority> getAuthorities(); // 권한들
Object getCredentials(); // 자격증명 (비밀번호 등)
Object getDetails(); // 상세정보 (IP, 세션ID 등)
Object getPrincipal(); // 주체 (UserDetails 객체)
boolean isAuthenticated(); // 인증 여부
}
SecurityContext와 SecurityContextHolder
현재 스레드의 보안 컨텍스트를 관리하는 클래스
SecurityContext: 현재 보안 정보를 담는 컨테이너SecurityContextHolder: 현재 스레드에서SecurityContext에 접근하는 유틸리티
// 현재 인증 정보 가져오기
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 인증된 사용자 정보 확인
if (auth != null && auth.isAuthenticated()) {
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
}
@AuthenticationPrincipal 어노테이션
컨트롤러에서 현재 로그인 사용자 정보를 쉽게 받아오는 어노테이션
// 기존 방식 (복잡)
@GetMapping("/profile")
public String profile() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
UserDetails user = (UserDetails) auth.getPrincipal();
return "사용자: " + user.getUsername();
}
// @AuthenticationPrincipal 사용 (간편)
@GetMapping("/profile_simple")
public String profileSimple(@AuthenticationPrincipal UserDetails userDetails) {
return "사용자: " + userDetails.getUsername();
}
전체 Spring Security 인증 흐름
- 로그인 요청 → 사용자가 username/password 입력
- 사용자 조회 →
UserDetailsService.loadUserByUsername()호출 - 비밀번호 검증 →
PasswordEncoder가 입력된 비밀번호와 저장된 비밀번호 비교 - 인증 성공 →
Authentication객체 생성 및 인증 상태 설정 - 보안 컨텍스트 저장 →
SecurityContext에Authentication저장 - 세션 생성 →
JSESSIONID쿠키 생성하여 클라이언트에 전송 - 이후 요청 → 세션 쿠키로 자동 인증,
@AuthenticationPrincipal로 사용자 정보 접근
4. JPA 활용 security 회원가입 만들어보기
- Spring Security + JPA 활용하여 n:n 권한과 유저 매핑 및 회원가입기능 심플 구현
- 회원가입 시 비밀번호 암호화 및 권한 부여 목적
디렉토리 구조
org.example.securityexam4
├── config
│ └── SecurityConfig.java
├── controller
│ └── UserController.java
├── domain
│ ├── Role.java
│ └── User.java
├── dto
│ └── UserRegisterDTO.java
├── repository
│ └── RoleRepository.java
│ └── UserRepository.java
├── service
│ └── UserService.java
└── SecurityExam4Application.java
그래들 의존성
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
apllication 설정
spring:
application:
name: securityexam
output:
ansi:
enabled: always
datasource:
url: jdbc:mysql://localhost:{포트번호}/{데이터베이스명}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: {접속계정}
password: {접속계정 비밀번호}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
엔티티 (domain)
Role.java
- 접근권한 엔티티
- 사용자와 n:n 관계 상태
@Entity
@Getter
@Setter
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String name;
}
User.java
- 유저 엔티티
- Role과 n:n 관계 상태이며, 조인테이블 "user_roles"로 매핑
@Entity
@Getter
@Table(name = "lion_users")
@Builder
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // PK
@Column(unique = true, nullable = false, length = 50)
private String username; // 유저아이디
@Column(nullable = false, length = 100)
private String password; // 비밀번호
@Column(nullable = false, length = 50)
private String name; // 이름
@Column(nullable = false, length = 100)
private String email; // 이메일
@Column(name = "registration_date", nullable = false, updatable = false)
@Builder.Default // 빌더시 기본값 적용
private LocalDate registrationDate = LocalDate.now(); // 가입일
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name="user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
}
DTO (dto)
UserRegisterDTO.java
- 유저 회원가입 DTO
- from 메서드로 User 엔티티를 DTO로 변환 기능 제공
@Builder
@Getter
@Setter
public class UserRegisterDTO {
private String username;
private String password;
private String name;
private String email;
private List<String> roles;
public static UserRegisterDTO from(User user) {
return UserRegisterDTO.builder()
.username(user.getUsername())
.password(user.getPassword())
.name(user.getName())
.email(user.getEmail())
.roles(user.getRoles().stream()
.map(Role::getName)
.toList())
.build();
}
}
레포지토리 (repository)
RoleRepository.java
- Role 레포지토리 인터페이스
- findByName 메서드로 역할 이름으로 조회 기능 제공
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(String name);
}
UserRepository.java
- User 레포지토리 인터페이스
- existsByUsername 메서드로 중복아이디 체크
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// User findByUsername(String username);
boolean existsByUsername(String username);
}
security 설정 (config)
SecurityConfig.java
- Spring Security 설정 클래스
- 시큐리티 인메모리 아닌 DB 연동하여 사용자 인증 처리
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// DB 연결 정보(커넥션 풀) 주입
private final DataSource dataSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/users/signup", "/users/userreg").permitAll()
.anyRequest().authenticated()
)
// .csrf(csrf -> csrf.disable())
.csrf(csrf -> csrf
.ignoringRequestMatchers("/users/userreg") // 회원가입 요청만 CSRF 예외
)
.formLogin(form -> form.defaultSuccessUrl("/users/welcome", true));
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
// InMemoryUserDetailsManager → 메모리 방식
// JdbcUserDetailsManager → DB 조회 방식
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
// 로그인 요청 시 사용자 정보 조회
// username, 암호화된 password 를 DB에서 가져옴
// enabled = true (계정 활성화)
manager.setUsersByUsernameQuery(
"SELECT username, password, true AS enabled FROM lion_users WHERE username = ?"
);
// 로그인 성공 후 사용자 권한 조회
// 유저 테이블(lion_users) + 조인 테이블(user_roles) + 권한 테이블(roles)
// DB에는 USER / ADMIN 저장되어 있으므로 ROLE_ prefix 붙여서 반환
manager.setAuthoritiesByUsernameQuery(
"SELECT u.username, CONCAT('ROLE_', r.name) " +
"FROM lion_users u " +
"JOIN user_roles ur ON u.id = ur.user_id " +
"JOIN roles r ON ur.role_id = r.id " +
"WHERE u.username = ?"
);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
서비스 (service)
UserService.java
- 유저 서비스 계층
- 회원가입 및 중복아이디 체크 기능 구현
- 패스워드 암호화 및 권한 매핑 처리
패스워드 암호화 관련 설명
- DB에 저장 시 평문 비밀번호 저장은 보안상 위험
PasswordEncoder를 사용하여 비밀번호를 암호화 후 저장- 로그인시 입력된 비밀번호를 암호화하여 DB에 저장된 암호화된 비밀번호와 비교
@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
/** 유저아이디 중복인지 */
public boolean existsByUsername(String username) {
return userRepository.existsByUsername(username);
}
/** 회원가입 */
public User registerUser(UserRegisterDTO registerDTO) {
Set<Role> roles = new HashSet<>();
// 회원가입시 권한 선택없으면 디폴트 USER
if (registerDTO.getRoles() == null || registerDTO.getRoles().isEmpty()) {
Role defaultRole = roleRepository.findByName("USER")
.orElseThrow(() -> new RuntimeException("USER 권한을 찾을 수 없습니다"));
roles.add(defaultRole);
} else {
// 선택한 권한들
for (String roleName : registerDTO.getRoles()) {
Role role = roleRepository.findByName(roleName)
.orElseThrow(() -> new RuntimeException(roleName + " 권한 찾을 수 없음"));
roles.add(role);
}
}
User user = User.builder()
.username(registerDTO.getUsername())
.name(registerDTO.getName())
.email(registerDTO.getEmail())
.password(passwordEncoder.encode(registerDTO.getPassword()))
.roles(roles)
.build();
log.info("username 들어온 값 = {}", registerDTO.getUsername());
log.info("builder username = {}", user.getUsername());
return userRepository.save(user);
}
}
컨트롤러 (controller)
UserController.java
- 유저 컨트롤러
- 회원가입 폼 및 회원가입 처리 기능 구현
- 실패시 : 중복아이디 경고 후 회원가입 폼으로 리다이렉트
- 성공시 : 환영 페이지로 리다이렉트
URL 매핑
- localhost:8080/login : 시큐리티 폼로그인
- localhost:8080/users/signup : 회원가입 폼 (GET)
- localhost:8080/users/userreg : 회원가입 처리 (POST)
- localhost:8080/users/welcome : 환영 페이지 (GET)
@Controller
@RequiredArgsConstructor
@RequestMapping("/users")
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping("/welcome")
public String welcome(Model model) {
return "exam4/welcome";
}
/** 회원가입 폼 */
@GetMapping("/signup")
public String register(Model model) {
// model.addAttribute("user", new UserRegisterDTO());
return "exam4/users/signup";
}
/** 회원가입 POST */
@PostMapping("/userreg")
public String userReg(
@ModelAttribute UserRegisterDTO userRegisterDTO
) {
if(userService.existsByUsername(userRegisterDTO.getUsername())) {
log.warn("이미 존재한 아이디 : " + userRegisterDTO.getUsername() );
return "redirect:/users/signup";
} else {
userService.registerUser(userRegisterDTO);
return "redirect:/users/welcome";
}
}
}
애플리케이션 메인
SecurityExam4Application.java
- 애플리케이션 메인 클래스
- 애플리케이션 시작 시
roles테이블에 초기값 권한 데이터 추가
@SpringBootApplication
@Slf4j
public class SecurityExam4Application {
public static void main(String[] args) {
SpringApplication.run(SecurityExam4Application.class, args);
}
@Bean
public CommandLineRunner commandLineRunner(
UserService userService,
RoleRepository roleRepo
) {
return args -> {
if (roleRepo.count() == 0) {
Role userRole = new Role();
userRole.setName("USER");
Role adminRole = new Role();
adminRole.setName("ADMIN");
roleRepo.saveAll(List.of(userRole, adminRole));
log.info("[TABLE roles] 초기값 권한 데이터 추가됨");
} else {
log.warn("[TABLE roles] 이미 초기값 존재함");
}
};
}
}
templates 화면
html 소스 확인
- resources/templates/exam4/users/signup.html
- 회원가입 폼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
</head>
<body>
<h2>회원 가입</h2>
<form action="/users/userreg" method="post">
<label for="username">아이디 : </label>
<input type="text" id="username" name="username" required><br><br>
<label for="password">비밀 번호 : </label>
<input type="password" id="password" name="password" required><br><br>
<label for="name">이름 : </label>
<input type="text" id="name" name="name" required><br><br>
<label for="email">이메일: </label>
<input type="text" id="email" name="email" required><br><br>
<label>권한 선택:</label><br>
<input type="checkbox" id="role_user" name="roles" value="USER">
<label for="role_user">USER</label><br>
<input type="checkbox" id="role_admin" name="roles" value="ADMIN">
<label for="role_admin">ADMIN</label><br><br>
<button type="submit">가입하기</button>
</form>
</body>
</html>
- resources/templates/exam4/welcome.html
- 로그인 후 리다이렉트 화면용
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>웰컴</title>
</head>
<body>
<h1>환영합니다.</h1>
</body>
</html>