의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT (Java JWT)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // for Jackson JSON parsing
해당 의존성 추가
Enum 클래스 설정
public enum Authority {
ROLE_READ,
ROLE_WRITE;
}
UserDetails 구현하기
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class MemberEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private List<String> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
getAuthorities만 구현한 상태. 역할을 반환함.
Service 구현하기
UserDetails를 구현함.
그리고 비밀번호 설정 시 함호화를 위한 PassEncoder 설정
PasswordEncoder 구현체 반환 AppConfig
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
로그인 및 회원가입시 요청 정보
model (DTO) 패키지
public class Auth {
// 로그인시 받을 요청 정보
@Data
public static class SignIn {
private String username;
private String password;
}
// 회원가입 시 받을 요청 정보
@Data
public static class SingUp {
private String username;
private String password;
private List<String> roles;
public MemberEntity toEntity() {
return MemberEntity.builder()
.username(this.username)
.password(this.password)
.roles(this.roles)
.build();
}
}
}
JWT(Json Web Token)
JWT는 로그인 기능 구현 시 여러가지 장점을 제공하는 토큰 기반 인증 방식이다.
인증
사용자가 로그인 할 때 서버에서 JWT를 생성함. 해당 토큰은 사용자 정보를 담고 있으며 사용자가 인증을 받은 후 클라이언트에 반환됨. 해당 클라이언트는 이후 요청 시 해당 토큰을 사용해 인증을 수행함.
정보 전달
JWT는 클레임 형태로 정보를 담을 수 있음. 클레임은 사용자 ID, 권한, 만료시간등 다양한 정보를 포함할 수 있으며 해당 정보를 서버가 아닌 클라이언트에서 직접 관리할 수 있어 유용함.
무상태
JWT는 서버의 상태를 저장할 필요가 없음 .해당 클라이언트는 토큰을 보유하고 있기 때문에 서버는 클라이언트의 상태를 관리하지 않아도 됨. 이는 서버간의 부하를 줄이고 수평 확장을 용이하게 함. 즉 회원의 정보를 서버가아닌 클라이언트 본인이 갖고있음
CSRF 공격 방지
JWT는 요청 헤더에 담겨 전송되므로, CSRF(사이트 간 요청 위조) 공격에 대한 방어력을 제공함.
유효성 검사
JWT는 시그니처를 포함하여 토큰의 무결성을 보장함. 서버는 토큰을 검증하여 토큰이 변조되지 않았는지 확인할 수 있음. 서명을 통해 클라이언트가 보낸 요청이 신뢰할 수 있는지 판단함.
결론
JWT 사용시 인증 및 정보 전달이 안전하고 효율적이며, 분산 시스템이나 마이크로서비스 아키텍처에서 유용하게 사용할 수 있음. 로그인 기능 구현 시 JWT를 사용하면 서버의 부담을 줄이고, 인증 및 권한 부여를 보다 효율적으로 처리할 수 있음.
![[Pasted image 20241002234736.png]]
JWT 사용시 주의 사항
JWT 토큰은 한번 만들어지면 서버에서 관리하지 않기 때문에 토큰 만료시간을 반드시 지정해줘야 한다.
그리고 Payload는 누구나 Decoding 시 열어서 정보를 확인할 수 있기 때문에 비밀번호와 같은 정보는 Payload에 담지 않는다.
JWT 생성 시 사용되는 알고리즘은 크게 2가지가 있다.
대칭 알고리즘 (HS512)
대칭 알고리즘에서는 같은 비밀 키를 사용하여 토큰을 생성하고 검증한다.
즉 서버에서만 비밀 키를 알고 있어야 하며, 해당 키가 유출되면 보안에 문제가 발생할 수 있다.
비대칭 알고리즘
비대칭 알고리즘에서는 개인 키로 토큰을 서명하고, 공개키로 검증한다. 이 경우 공개키는 클라이언트와 공유할 수 있으므로 보다 안전하게 사용할 수 있다.
대칭 알고맂므은 성능이 좋고 간단하지만 비밀 키의 유출 위험이 있고, 비대칭 알고리즘은 보안성이 높지만 성능이 떨어진다.
JWT 사용하기
먼저 터미널을 열어서 인코딩된 암호화 키를 만들어준
![[Pasted image 20241004174025.png]]
해당 명령어는 64비트의 랜덤한 문자열을 생성해준다.
해당 인코딩된 문자열을 프로젝트 내의 yml파일에 명시해준다.
jwt:
secret: QlEJIVitDWLhEWCc/khwgutWYsO4/fcdTXWPKWNFFy2opsxXnCbIT/uMDwCn092GxslgtlkOB7uk1ekgE/d0xw==
토큰을 생성할 클래스와 로직을 짜준다.
@Component
@RequiredArgsConstructor
public class TokenProvider {
private static final long TOKEN_EXPIRATION_TIME = 60 * 60 * 1000 * 5; // 5hour
private static final String KEY_ROLES = "roles";
@Value("${spring.jwt.secret}")
private String secretKey;
/**
* 토큰 생성(발급)
* @param username
* @param roles
* @return
*/
public String generateToken(String username, List<String> roles) {
// 사용자의 권한정보를 저장하기 위한 Claims Claims claims = Jwts.claims().setSubject(username);
claims.put(KEY_ROLES, roles);
// 토큰이 생성된 시간
Date now = new Date();
// 토큰이 만료되는 시간
Date expiredDate = new Date(now.getTime() + TOKEN_EXPIRATION_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now) // 토큰 생성시간
.setExpiration(expiredDate) // 토큰 만료 시간
.signWith(SignatureAlgorithm.HS512, this.secretKey) // 사용할 암호화 알고리즘
.compact();
}
public String getUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean validateToken(String token) {
if (!StringUtils.hasText(token)) return false;
Claims claims = parseClaims(token);
return !claims.getExpiration().before(new Date()); // 현재 토큰의 만료시간이 이전인지 아닌지 check }
// 토큰이 유효한지 검증
private Claims parseClaims(String token) {
// 토큰 시간이 만료된 상태에서 파싱할 경우 에러가 날 수 있음
try {
return Jwts.parser().setSigningKey(this.secretKey).parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
그래서 위와같이 generateToken을 통해 로그인한 User에게는 Token을 부여할 수 있도록 Controller에서 로직을 짤 수 있다.
Controller
@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
private final MemberService memberService;
private final TokenProvider tokenProvider;
@PostMapping("/signup")
public ResponseEntity<?> signup(@RequestBody Auth.SignUp request) {
//회원가입을 위한 API MemberEntity register = memberService.register(request);
return ResponseEntity.ok(register);
}
@PostMapping("/signin")
public ResponseEntity<?> signin(@RequestBody Auth.SignIn request) {
//로그인용 API MemberEntity user = memberService.authenticate(request);
String token = tokenProvider.generateToken(user.getUsername(), user.getRoles());
return ResponseEntity.ok(token);
}
}
Service
// 회원가입
@Transactional
public MemberEntity register(Auth.SignUp member) {
if (memberRepository.existsByUsername(member.getUsername())) {
throw new RuntimeException("username already in use" + member.getUsername());
}
// 암호화 과정
member.setPassword(passwordEncoder.encode(member.getPassword()));
// DB에 엔티티 저장
return memberRepository.save(member.toEntity());
}
// 로그인 검증
@Transactional
public MemberEntity authenticate(Auth.SignIn member) {
MemberEntity user = memberRepository.findByUsername(member.getUsername())
.orElseThrow(() -> new UsernameNotFoundException(
"존재하지 않는 ID 입니다: " + member.getUsername()));
// DB에서 찾은 user의 password는 인코딩된 암호임. 파라미터로 받은 member는 인코딩 되지 않은 것
if (!passwordEncoder.matches(member.getPassword(), user.getPassword())) {
throw new RuntimeException("비밀번호가 일치하지 않습니다 ");
}
return user;
}
JWT 인증 필터
클라이언트로부터 들어온 사용자의 정보를 가지고, 해당 토큰이 유효한지 아닌지 검증을 진행함
@Slf4j
@Component
@RequiredArgsConstructor // 한 요청당 한번 필터가 실행됨.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 토큰은 Http 헤더에 포함됨, 어떤 키로 토큰을 주고받을지에 대한 키값
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer "; // Bearer xxxx-yyyy.zzz 로 들어옴
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청에 토큰을 포함시켜서 해당 토큰이 유효한지 아닌지 확인
String token = resolveTokenFromRequest(request);
//null 이 아니고 값이 아니며 유효한지 검증
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String resolveTokenFromRequest(HttpServletRequest request) {
// HttpServlet으로 부터 토큰을 가지고옴
String token = request.getHeader(TOKEN_HEADER);
// 토큰이 존재하면서 Totekn Prefix로 시작을 한다면
if (!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) {
log.info("TOKEN = {}", token.substring(TOKEN_PREFIX.length()));
return token.substring(TOKEN_PREFIX.length()); // 실제 토큰 값을 반환
}
return null;
}
}
TokenProvider
해당 메서드 추가
// jwt 로 부터 인증 정보를 가지고 오기
public Authentication getAuthentication(String jwt) {
UserDetails userDetails = memberService.loadUserByUsername(getUsername(jwt));
// 사용자의 정보와 사용자의 권한정보를 넘김
return new UsernamePasswordAuthenticationToken(userDetails,"", userDetails.getAuthorities());
}
SecurityConfiguartion
@Slf4j
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(AbstractHttpConfigurer::disable) // httpBasic().disable() 대신 사용
.csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화
/*Rest API로 JWT 토큰으로 인증방식을 정의할 때 붙여주는 부분 */ .authorizeHttpRequests(auth -> auth
.requestMatchers("/signup", "/signin").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
/*실제 경로에 대한 권한제어 인증 부분*/
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
1. http.httpBasic(AbstractHttpConfigurer::disable)
• 의미: HTTP 기본 인증 방식 (Basic Authentication)을 비활성화하는 부분입니다.
• 이유: JWT 토큰 기반 인증을 사용할 때는 기본 인증 방식이 필요 없기 때문에 비활성화합니다. httpBasic().disable() 방식이 Spring Security 6.x에서 변경되어 AbstractHttpConfigurer::disable을 사용하게 되었습니다.
2. http.csrf(AbstractHttpConfigurer::disable)
• 의미: CSRF (Cross-Site Request Forgery) 보호 기능을 비활성화합니다.
• 이유: CSRF 보호는 주로 브라우저 기반의 세션을 사용하는 애플리케이션에 유효한데, REST API와 JWT를 사용하는 경우 세션을 사용하지 않으므로 CSRF 보호를 비활성화해도 괜찮습니다.
3. sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
• 의미: 서버에서 세션을 생성하지 않도록 설정합니다. SessionCreationPolicy.STATELESS는 애플리케이션이 상태를 유지하지 않음을 의미합니다.
• 이유: REST API와 JWT 기반 인증에서는 서버가 세션을 유지할 필요가 없으므로 상태를 유지하지 않는 STATELESS 모드를 사용합니다. 인증 정보는 JWT로 관리되기 때문에 서버는 세션을 저장할 필요가 없습니다.
4. .authorizeHttpRequests(auth -> auth.requestMatchers("/signup", "/signin").permitAll()
• 의미: /signup과 /signin 경로는 인증 없이 누구나 접근할 수 있도록 허용합니다.
• 이유: 회원가입 및 로그인 API는 누구나 접근할 수 있어야 하므로 permitAll()을 사용해 인증 절차를 거치지 않도록 설정한 것입니다.
5. .anyRequest().authenticated()
• 의미: 위에서 명시한 경로를 제외한 나머지 모든 요청은 인증된 사용자만 접근할 수 있게 합니다.
• 이유: 나머지 경로는 JWT 토큰을 통해 인증된 사용자만 접근할 수 있도록 하기 위함입니다.
6. .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
• 의미: jwtAuthenticationFilter를 스프링 시큐리티 필터 체인에서 UsernamePasswordAuthenticationFilter 앞에 추가합니다.
• 이유: JWT 인증 필터를 사용자 이름과 비밀번호 인증 필터보다 앞에 두어, 요청이 들어오면 먼저 JWT를 검증하도록 하기 위함입니다. JWT 토큰을 기반으로 인증을 수행해야 하므로 이 순서가 중요합니다.
결론
이 설정은 REST API를 JWT 토큰으로 인증하는 방식을 구현한 것으로, 기본 인증이나 세션을 사용하지 않고, JWT를 통해 모든 인증을 처리하기 위한 설정입니다. AbstractHttpConfigurer::disable로 표현하는 것이 Spring Security 6.x에 맞는 새로운 문법입니다.
REST API가 아닐 경우
웹 페이지를 제작할 때는 CSRF (Cross-Site Request Forgery) 보호 기능을 활성화하는 것이 권장됩니다. 특히 브라우저 기반 세션을 사용하는 경우, 사용자가 로그인한 상태에서 악의적인 사이트가 사용자의 권한을 이용해 요청을 보낼 수 있는 CSRF 공격을 방어해야 하기 때문입니다.
CSRF 보호를 활성화해야 하는 이유:
• 세션 기반 인증에서는 서버가 클라이언트 세션을 유지하기 때문에 공격자가 사용자의 세션을 악용해 불법적인 요청을 할 수 있습니다.
• 폼 기반 인증이나 쿠키 기반 인증을 사용하는 웹 페이지는 CSRF 공격에 취약할 수 있습니다.
• Spring Security는 CSRF 보호를 활성화하면, 폼 제출 시 CSRF 토큰을 함께 전송하도록 요구합니다. 이 토큰은 서버가 생성하고, 요청 시 유효성을 검증하여 공격을 차단합니다.
http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/member/**").hasAnyRole("MEMBER", "ADMIN")
.requestMatchers("/admin/**", "/notices/reg").hasRole("ADMIN")
.requestMatchers("/myshop/**").hasRole("MEMBER")
.anyRequest().permitAll())
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
이 코드는 CSRF 보호를 활성화하면서, 토큰을 쿠키에 저장해 클라이언트가 폼 제출 시 해당 토큰을 서버로 전송하도록 합니다.
Spring Security, JWT 로그인 흐름
@PostMapping("/signup")
public ResponseEntity<?> signup(@RequestBody Auth.SignUp request) {
//회원가입을 위한 API MemberEntity register = memberService.register(request);
return ResponseEntity.ok(register);
}
@PostMapping("/signin")
public ResponseEntity<?> signin(@RequestBody Auth.SignIn request) {
//로그인용 API MemberEntity user = memberService.authenticate(request);
String token = tokenProvider.generateToken(user.getUsername(), user.getRoles());
return ResponseEntity.ok(token);
}
}
해당 기능을 통해 로그인 및 회원가입을 진행
### Post signup
POST localhost:8080/signup
Content-Type: application/json
{
"username": "master",
"password": "grace123!@#",
"roles": ["ROLE_WRITE"]
}
이런식으로 권한을 부여할 수 있음 (roles)
권한 부여 후 로그인을 통해 JWT 토큰이 부여됨.
![[Pasted image 20241004183341.png]]
토큰이 부여되면 해당 토큰을 Http Header에 넣어줌으로 써 인증이 가능하게됨.
/*회사 검색*/
@GetMapping("/company")
@PreAuthorize("hasRole('READ')")
public ResponseEntity<?> searchCompany(@RequestParam String ticker) {
return ResponseEntity.ok(companyService.getCompany(ticker));
}/*회사 등록*/
@PostMapping("/company")
@PreAuthorize("hasRole('WRITE')")
public ResponseEntity<?> addCompany(@RequestBody Company request) {
if (ObjectUtils.isEmpty(request.getTicker())) {
throw new RuntimeException("Ticker cannot be empty -> " + request.getTicker());
}
Company company = companyService.save(request.getTicker());
companyService.addAutocompleteKeyword(company.getName());
return ResponseEntity.ok(company);
}
위에 메서드의 @PreAuthorize가 있는걸 볼 수 있음.
해당 애노테이션을통해 필터에서 JWT 토큰을 보내서 해당 정보를 읽고 일기권한만 가능한지, 쓰기 권한이 가능한지의 여부에 따라 회사 등록은 WRITE, 회사 정보 불러오기는 READ 권한만 부여된 유저만 사용가능하게 할 수있음.
### Get searchAllCompany
GET localhost:8080/companies
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtYXN0ZXIiLCJyb2xlcyI6WyJST0xFX1dSSVRFIl0sImlhdCI6MTcyODAzMjA5MSwiZXhwIjoxNzI4MDUwMDkxfQ.FWRzOVVmR1I1AEs2ziEfAGXCYq8gHgM0THShht_xtz9CGLW5al_qXeCBiw7c11BruuDrgiZb_fSek6u5GTZLeQ
Accept: application/json
content-Type: application/json
### Post addCompany
POST localhost:8080/company
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtYXN0ZXIiLCJyb2xlcyI6WyJST0xFX1dSSVRFIl0sImlhdCI6MTcyODAzMjA5MSwiZXhwIjoxNzI4MDUwMDkxfQ.FWRzOVVmR1I1AEs2ziEfAGXCYq8gHgM0THShht_xtz9CGLW5al_qXeCBiw7c11BruuDrgiZb_fSek6u5GTZLeQ
Accept: application/json
Content-Type: application/json
{
"ticker": "COKE"
}
이런식으로 토큰을 header에 담아서 같이 보냄
'프로젝트 이슈 및 몰랐던점 정리 > StockDividendAPI' 카테고리의 다른 글
[설정] 백엔드 API, HTTP 기본 요청 매핑 및 RestControllerAdvice예외처리 (0) | 2024.10.07 |
---|---|
[설정] Redis Cache 사용하기 및 스프링부트로 Redis 접근하기 (0) | 2024.10.07 |
[설정] 복합 유니크 키 설정 및 DB Index (0) | 2024.10.07 |
[설정] 삽입하려는 데이터의 중복 키 발생 시 레코드 무시하고 삽입하는 방법 (0) | 2024.10.07 |
[설정] 백엔드 API Pageable 사용하기 (0) | 2024.10.07 |