plugins {
id 'org.springframework.boot' version '2.7.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'the.plural'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
데이터베이스는 mysql
을 사용하고 Jwt
토큰 기반 방식으로 로그인할 것이기 때문에 jwt
관련 설정을 추가해준다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/데이터베이스 이름?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: 유저네임
password: 비밀번호
security:
jwt:
header: Authorization
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
token-validity-in-seconds: 86400
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
generate-ddl: true
hibernate:
ddl-auto: update
show_sql: true
format_sql: true
# default_batch_fetch_size: 1000
logging.level:
org.hibernate.SQL: debug
org.hibernate.type: trace
# parameter Binding
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
엔티티의 생성시간과 수정시간 컬럼을 만들기 위해서 BaseTimeEntity
클래스를 생성한다.
@EnableJpaAuditing // 이 부분
@SpringBootApplication
public class BerrApplication {
public static void main(String[] args) {
SpringApplication.run(BerrApplication.class, args);
}
}
BaseTimeEntity
를 만들었다면 꼭 Application
에 @EnableJpaAuditing
을 추가해줘야 한다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Entity
public class Member extends BaseTimeEntity {
@Id @Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 45, unique = true)
private String email;
@Column(length = 45)
private String nickname;
private int age;
@Column(length = 100)
private String password;
@Enumerated(EnumType.STRING)
private Role role;
public void encodePassword(PasswordEncoder passwordEncoder){
this.password = passwordEncoder.encode(password);
}
로그인할 ID
는 Email
이다.
public enum Role {
USER, MANAGER, ADMIN;
}
enum
클래스인 것에 주의해서 설계하자
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
Extends
는 JpaRepository<엔티티, 엔티티 PK 타입>
이다.
findByEmail
은 JPA에서 지원하는 쿼리 메소드
이며 이메일이 일치하는 Member
를 찾아준다.
@Data
@Builder
@AllArgsConstructor
public class MemberSignUpRequestDto {
@NotBlank(message = "아이디를 입력해주세요")
private String email;
@NotBlank(message = "닉네임을 입력해주세요.")
@Size(min=2, message = "닉네임이 너무 짧습니다.")
private String nickname;
@NotNull(message = "나이를 입력해주세요")
@Range(min = 0, max = 150)
private int age;
@NotBlank(message = "비밀번호를 입력해주세요")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,30}$",
message = "비밀번호는 8~30 자리이면서 1개 이상의 알파벳, 숫자, 특수문자를 포함해야합니다.")
private String password;
private String checkedPassword;
private Role role;
@Builder
public Member toEntity(){
return Member.builder()
.email(email)
.nickname(nickname)
.age(age)
.password(password)
.role(Role.USER)
.build();
}
}
서비스 계층은 인터페이스를 분리해서 만들어보자
public interface MemberService {
// 회원가입
public Long signUp(MemberSignUpRequestDto requestDto) throws Exception;
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
@Override
public Long signUp(MemberSignUpRequestDto requestDto) throws Exception {
if (memberRepository.findByEmail(requestDto.getEmail()).isPresent()){
throw new Exception("이미 존재하는 이메일입니다.");
}
if (!requestDto.getPassword().equals(requestDto.getCheckedPassword())){
throw new Exception("비밀번호가 일치하지 않습니다.");
}
Member member = memberRepository.save(requestDto.toEntity());
member.encodePassword(passwordEncoder);
member.addUserAuthority();
return member.getId();
}
}
Exception
핸들러는 없다치고 Exception
을 사용하겠다.
Member에 미리 만들어 놓은 encodePassword
메서드를 사용해서 비밀번호를 암호화
한다.
@RequiredArgsConstructor
@RequestMapping("/member")
@RestController
public class MemberController {
private final MemberService memberService;
private final MemberRepository memberRepository;
@PostMapping("/join")
@ResponseStatus(HttpStatus.OK)
public Long join(@Valid @RequestBody MemberSignUpRequestDto request) throws Exception {
return memberService.signUp(request);
}
이걸로 회원가입은 끝이났다. 로그인을 구현해보자.
로그인을 하기 위해서 SecurityConfig
클래스를 만들어 줍니다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin().disable()
.httpBasic().disable()
.cors().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/member/login").permitAll()
.antMatchers("/member/join").permitAll()
.antMatchers("/member").hasRole("USER")
.anyRequest().authenticated();
}
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
private static final String[] AUTH_WHITELIST = {
"/v2/api-docs",
"/v3/api-docs/**",
"/configuration/ui",
"/swagger-resources/**",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
"/file/**",
"/image/**",
"/swagger/**",
"/swagger-ui/**",
"/h2/**"
};
// 정적인 파일 요청에 대해 무시
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers(AUTH_WHITELIST);
}
}
public class SecurityUtil {
public static String getLoginUsername(){
UserDetails user = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return user.getUsername();
}
}
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey =
"c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK";
// 토큰 유효시간 168 시간(7일)
private long tokenValidTime = 1440 * 60 * 7 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey 를 Base64로 인코딩합니다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key/value 쌍으로 저장됩니다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘
// signature 에 들어갈 secret 값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtAuthenticationProvider;
public JwtAuthenticationFilter(JwtTokenProvider provider) {
jwtAuthenticationProvider = provider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtAuthenticationProvider.resolveToken(request);
if (token != null && jwtAuthenticationProvider.validateToken(token)) {
Authentication authentication = jwtAuthenticationProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
public String login(Map<String, String> members);
@Override
public String login(Map<String, String> members) {
Member member = memberRepository.findByEmail(members.get("email"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 Email 입니다."));
String password = members.get("password");
if (!member.checkPassword(passwordEncoder, password)) {
throw new IllegalArgumentException("잘못된 비밀번호입니다.");
}
List<String> roles = new ArrayList<>();
roles.add(member.getRole().name());
return jwtTokenProvider.createToken(member.getUsername(), roles);
@PostMapping("/login")
public String login(@RequestBody Map<String, String> member) {
return memberService.login(member);
}