java 17
Spring 3.3.0
Postgresql 15.5
CREATE SEQUENCE users_seq START WITH 1;
COMMENT ON SEQUENCE users_seq IS 'users 테이블에 대한 유일한 ID를 생성하기 위한 시퀀스';
CREATE TABLE users (
id BIGINT DEFAULT nextval('users_seq') PRIMARY KEY,
username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(100) ,
phone VARCHAR(20),
authority VARCHAR(5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
)
;
COMMENT ON TABLE users IS '사용자 정보를 저장하는 테이블';
COMMENT ON COLUMN users.id IS '사용자를 식별하는 고유값';
COMMENT ON COLUMN users.username IS '사용자의 계정 이름';
COMMENT ON COLUMN users.password IS '사용자릐 계정 암호';
COMMENT ON COLUMN users.email IS '사용자의 이메일';
COMMENT ON COLUMN users.phone IS '사용자의 전화번호';
COMMENT ON COLUMN users.authority IS '사용자의 권한';
COMMENT ON COLUMN users.created_at IS '생성일시';
COMMENT ON COLUMN users.updated_at IS '변경일시';
COMMENT ON COLUMN users.deleted_at IS '삭제일시';
// Spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
jwt:
secret: [secret key]
expirationTime: [expiration time]
비밀번호 인코더 빈 생성
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
RESTFUL API에서 불필요하여 CSRF를 비활성화
http.csrf(AbstractHttpConfigurer::disable);
예외 처리 구성
http.exceptionHandling(exception ->
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler));
세션을 STATELESS로 설정
http.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
JWT 필터 추가
http.addFilterBefore(new JwtFilter(tokenProvider, customUserDetailsService), UsernamePasswordAuthenticationFilter.class);
인증 없이 접근할 수 있는 엔드포인트 설정
return web -> web.ignoring().requestMatchers(
"/swagger-ui/**",
"/signUp",
"/signIn");
전체 코드
import com.boilerplate.jwt.*;
import com.boilerplate.service.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CustomUserDetailsService customUserDetailsService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(exception ->
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
).authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.anyRequest().authenticated()
)
.addFilterBefore(new JwtFilter(tokenProvider, customUserDetailsService), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public WebSecurityCustomizer configure() {
return web -> web.ignoring().requestMatchers(
"/swagger-ui/**",
"/signUp",
"/signIn"
);
}
}
import com.boilerplate.entitiy.Users;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Collection;
import java.util.List;
import java.util.ArrayList;
@Component
public class TokenProvider implements InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private final String secret;
private final Long expirationTime;
private Key key;
public TokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.expirationTime}") long expirationTime) {
this.secret = secret;
this.expirationTime = expirationTime;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createToken(Users uesrs) {
Claims claims = Jwts.claims();
claims.put("username", uesrs.getUsername());
claims.put("email", uesrs.getEmail());
claims.put("phone", uesrs.getPhone());
claims.put("authority", uesrs.getAuthority());
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(this.expirationTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(this.key, SignatureAlgorithm.HS256)
.compact();
}
public Collection<? extends GrantedAuthority> getAuthentication(String token) {
Claims claims = getAllClaims(token);
List<String> authorites = new ArrayList<>();
authorites.add("ROLE_"+ claims.get("authority").toString());
return authorites.stream()
.map(SimpleGrantedAuthority::new)
.toList();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("Invalid JWT Token");
} catch (ExpiredJwtException e) {
logger.info("Expired JWT Token");
} catch (UnsupportedJwtException e) {
logger.info("Unsupported JWT Token");
} catch (IllegalArgumentException e) {
logger.info("JWT claims string is empty");
}
return false;
}
public Claims getAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(this.secret).build()
.parseClaimsJws(token)
.getBody();
}
}
import com.boilerplate.service.CustomUserDetailsService;
import com.querydsl.core.util.StringUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.GenericFilterBean;
import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
private static final Logger log = LoggerFactory.getLogger(JwtFilter.class);
private final TokenProvider tokenProvider;
private final CustomUserDetailsService customUserDetailsService;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
String username = String.valueOf(tokenProvider.getAllClaims(jwt).get("username"));
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
if (!StringUtils.isNullOrEmpty(jwt) && tokenProvider.validateToken(jwt)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, tokenProvider.getAuthentication(jwt));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
log.debug("save {} in security config / uri : {}", usernamePasswordAuthenticationToken.getName(), requestURI);
} else {
log.debug("JWT is empty or null / uri : {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
Optional<Cookie> authorizationCookie = Arrays.stream(cookies)
.filter(cookie -> "Autentication".equals(cookie.getName()))
.findFirst();
String bearerToken = authorizationCookie.map(Cookie::getValue).orElse(null);
if (!StringUtils.isNullOrEmpty(bearerToken)) {
return bearerToken;
}
return null;
}
}
import com.boilerplate.dto.SignInRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final transient SignInRequest signInRequest;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return null;
}
}
import com.boilerplate.dto.SignInRequest;
import com.boilerplate.entitiy.Users;
import com.boilerplate.jwt.CustomUserDetails;
import com.boilerplate.repository.CustomUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final CustomUserRepository customUserRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Users users = customUserRepository.findOneWithAuthoritiesByUsername(username);
SignInRequest signInRequest = SignInRequest.builder()
.username(users.getUsername())
.password(users.getPassword())
.build();
return new CustomUserDetails(signInRequest);
}
}
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
import com.boilerplate.enums.UserAuthority;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name="users")
@SequenceGenerator(
name = "users_seq",
sequenceName = "users_seq",
allocationSize = 1
)
@Getter
public class Users {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq")
private Long id;
@Column(name = "username", length = 50)
private String username;
@Column(name = "password", length = 255)
private String password;
@Column(name = "email", length = 100)
private String email;
@Column(name = "phone", length = 100)
private String phone;
@Column(name="authority")
@Enumerated(EnumType.STRING)
private UserAuthority authority;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Builder
public Users(String username, String password, String email, String phone, LocalDateTime createdAt) {
this.username = username;
this.password = password;
this.email = email;
this.phone = phone;
this.createdAt = createdAt;
}
}
import com.boilerplate.entitiy.QUsers;
import com.boilerplate.entitiy.Users;
import com.boilerplate.repository.CustomUserRepository;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@AllArgsConstructor
public class CustomUserRepositoryImpl implements CustomUserRepository {
private final JPAQueryFactory queryFactory;
@Override
public boolean existsByUsername(String username) {
QUsers user = QUsers.users;
return queryFactory
.select(user.username)
.from(user)
.where(user.username.eq(username)
.and(user.deletedAt.isNull()))
.fetchFirst() != null;
}
@Override
public Users findOneWithAuthoritiesByUsername(String username) {
QUsers user = QUsers.users;
return queryFactory
.select(user)
.from(user)
.where(user.username.eq(username)
.and(user.deletedAt.isNull()))
.fetchOne();
}
}
import com.boilerplate.dto.SignInRequest;
import com.boilerplate.dto.SignUpRequest;
import com.boilerplate.entitiy.Users;
import com.boilerplate.jwt.TokenProvider;
import com.boilerplate.repository.CustomUserRepository;
import com.boilerplate.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final CustomUserRepository customUserRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
private final TokenProvider tokenProvider;
public boolean registerUSer(SignUpRequest signUpRequest) {
String username = signUpRequest.getUsername();
boolean isExists = this.customUserRepository.existsByUsername(username);
if (isExists) {
return false;
}
Users user = Users.builder()
.username(signUpRequest.getUsername())
.password(bCryptPasswordEncoder.encode(signUpRequest.getPassword()))
.email(signUpRequest.getEmail())
.phone(signUpRequest.getPhone())
.createdAt(LocalDateTime.now())
.build();
this.userRepository.save(user);
return true;
}
@Transactional
public String authenticateUser(SignInRequest signInRequest) throws UsernameNotFoundException{
Users users = this.customUserRepository.findOneWithAuthoritiesByUsername(signInRequest.getUsername());
if (bCryptPasswordEncoder.matches(signInRequest.getPassword(), users.getPassword())) {
return tokenProvider.createToken(users);
}
throw new UsernameNotFoundException(signInRequest.getUsername() + " is not found.");
}
}
import com.boilerplate.dto.SignInRequest;
import com.boilerplate.dto.SignUpRequest;
import com.boilerplate.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.boilerplate.response.MessageResponse;
@Tag(name = "Sign up", description = "회원가입 API")
@RestController
@ResponseBody
@RequiredArgsConstructor
public class AuthController {
@Value("${jwt.expirationTime}")
private int expirationTime;
@Value("${cookie.SameSite}")
private String sameSite;
private final AuthService authService;
@Operation(summary = "로그인", description = "로그인")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
@ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
@PostMapping(path = "signIn")
public ResponseEntity<MessageResponse> authenticateUser(@RequestBody SignInRequest signInDto, HttpServletResponse response) {
String accessToekn = authService.authenticateUser(signInDto);
Cookie cookie = new Cookie("Autentication", accessToekn);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setMaxAge(expirationTime);
cookie.setAttribute("SameSite", this.sameSite);
response.addCookie(cookie);
return ResponseEntity.status(HttpStatus.OK).body(new MessageResponse("success", 200));
}
}