Spring Security: 자바 애플리케이션의 인증(Authentication)과 권한(Authorization)을 관리하는 프레임워크
| 개념 | 설명 |
|---|---|
| 인증 | 사용자가 누구인지 확인 (로그인) |
| 권한 | 인증된 사용자가 뭘 할 수 있는지 (역할) |
| 세션 | 서버가 사용자 정보를 메모리에 저장 |
| CSRF | 다른 사이트에서 내 계정으로 요청하는 공격 |
| 방식 | 사용자 저장 | 회원가입 | 실무 |
|---|---|---|---|
| 기본 설정 | 메모리 | 불가 | X (테스트만) |
| 커스텀 로그인 | 메모리 | 불가 | X (학습용) |
| DB 기반 | DB | 가능 | O (실무) |
# application.yml
spring:
datasource:
driver-class-name: oracle.jdbc.OracleDriver
url: jdbc:oracle:thin:@localhost:1521/XEPDB1
username: springboot
password: java1234
jpa:
database: oracle
hibernate:
ddl-auto: none
MainController → 페이지 라우팅index.html, member.html, admin.html → 페이지inc/header.html → 공통 헤더@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// URI 권한 설정
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/join", "/joinok").permitAll()
.requestMatchers("/member").hasAnyRole("MEMBER", "ADMIN")
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
);
// 커스텀 로그인
http.formLogin(auth -> auth
.loginPage("/login")
.loginProcessingUrl("/loginok")
);
// 예외 처리
http.exceptionHandling(auth -> auth
.authenticationEntryPoint((req, res, e) -> res.sendRedirect("/login")) // 401
.accessDeniedHandler((req, res, e) -> res.sendRedirect("/denied")) // 403
);
return http.build();
}
@Bean
BCryptPasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
}
| 메서드 | 설명 |
|---|---|
permitAll() | 모두 접근 가능 |
authenticated() | 인증된 사용자만 |
hasRole("ADMIN") | ROLE_ADMIN만 |
hasAnyRole("MEMBER","ADMIN") | 둘 중 하나 |
denyAll() | 접근 불가 |
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
AuthController.java:
@Controller
public class AuthController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/denied")
public String denied() {
return "denied";
}
}
login.html:
<form method="POST" action="/loginok">
<input type="text" name="username" required>
<input type="password" name="password" required>
<button>로그인</button>
<!-- CSRF 토큰 필수! -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
script.sql:
create table member(
username varchar2(50) PRIMARY KEY,
password varchar2(100) not null,
age number(3),
email varchar2(50),
role varchar2(50) -- ROLE_MEMBER, ROLE_ADMIN
);
Member Entity:
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
@Id
private String username;
private String password;
private Integer age;
private String email;
private String role;
}
MemberDto:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberDto {
private String username;
private String password;
private Integer age;
private String email;
private String role;
}
MemberRepository:
public interface MemberRepository extends JpaRepository<Member, String> {
}
MemberService - ⭐ 비밀번호 암호화:
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository repo;
private final BCryptPasswordEncoder encoder;
public void join(MemberDto dto) {
Member member = Member.builder()
.username(dto.getUsername())
.password(encoder.encode(dto.getPassword())) // ⭐ 암호화 필수!
.role(dto.getRole())
.age(dto.getAge())
.email(dto.getEmail())
.build();
repo.save(member);
}
}
MemberController:
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService service;
@GetMapping("/join")
public String join() {
return "join";
}
@PostMapping("/joinok")
public String joinok(MemberDto dto) {
service.join(dto);
return "redirect:/login";
}
@GetMapping("/member")
public String member() {
return "member";
}
}
비밀번호 안전 암호화
// 암호화
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String encoded = encoder.encode("1234"); // $2a$10$... (매번 다름)
// 검증 (로그인 시 자동)
boolean match = encoder.matches("1234", encoded); // true
특징:
CustomUserDetails - 인증 객체:
@Getter
public class CustomUserDetails implements UserDetails {
private Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> member.getRole());
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
}
CustomUserDetailsService - ⭐ 핵심:
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository repo;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> member = repo.findById(username);
if(member.isPresent()) {
return new CustomUserDetails(member.get()); // ⭐ 이게 Spring Security가 찾음
} else {
throw new UsernameNotFoundException(username);
}
}
}
| HTTP Code | 상황 | 해결 |
|---|---|---|
| 401 | 익명 사용자 인증 필요 | /login으로 리다이렉트 |
| 403 | 권한 없음 | /denied로 리다이렉트 |
http.exceptionHandling(auth -> auth
.authenticationEntryPoint((req, res, e) -> res.sendRedirect("/login"))
.accessDeniedHandler((req, res, e) -> res.sendRedirect("/denied"))
);
사용자 /login 접근
↓
login.html 표시 (username, password 입력)
↓
POST /loginok (username, password, CSRF 토큰)
↓
Spring Security 필터 가로채기
↓
CustomUserDetailsService.loadUserByUsername(username) 호출
↓
DB에서 Member 조회
↓
CustomUserDetails 객체 생성
↓
BCryptPasswordEncoder.matches() 비밀번호 검증
↓
[일치] → 세션 저장 → 권한에 따라 페이지 이동
[불일치] → /login으로 리다이렉트 (실패 메시지)
// 잘못된 예
.password(dto.getPassword()) // 평문 저장!
// 올바른 예
.password(encoder.encode(dto.getPassword())) // ✅ 암호화
// 잘못된 예
// CustomUserDetailsService 없으면 DB를 모르므로 로그인 실패
// 올바른 예
@Service
public class CustomUserDetailsService implements UserDetailsService {
// ✅ 반드시 필요!
}
<!-- 잘못된 예 -->
<form method="POST" action="/loginok">
<!-- CSRF 공격에 취약 -->
</form>
<!-- 올바른 예 -->
<form method="POST" action="/loginok">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
// 잘못된 예
http.formLogin(auth -> auth
.loginPage("/login")
// loginProcessingUrl 없으면 기본값 /login 사용 (혼동!)
);
// 올바른 예
http.formLogin(auth -> auth
.loginPage("/login") // GET (페이지 표시)
.loginProcessingUrl("/loginok") // POST (로그인 처리)
);
com.test.java/
├── config/
│ └── SecurityConfig.java .... 보안 설정
├── controller/
│ ├── MainController.java
│ ├── AuthController.java ..... /login, /denied
│ └── MemberController.java ... /join, /member
├── entity/
│ └── Member.java ............ DB 테이블
├── dto/
│ ├── MemberDto.java
│ └── CustomUserDetails.java .. 인증 객체
├── repository/
│ └── MemberRepository.java
└── service/
├── MemberService.java ...... 회원 가입 (비밀번호 암호화)
└── CustomUserDetailsService.java 로그인 서비스