Skip to content

Commit

Permalink
Merge pull request #28 from Kusitms-28th-HDmedi-B/feat/login
Browse files Browse the repository at this point in the history
Feat/login
  • Loading branch information
hojeong2747 authored Sep 13, 2023
2 parents 7e11e01 + de6b0ca commit 3c3e0fa
Show file tree
Hide file tree
Showing 19 changed files with 694 additions and 2 deletions.
6 changes: 4 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
Expand All @@ -33,9 +32,12 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'com.auth0:java-jwt:4.2.1'
}

tasks.named('test') {
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/kusitms/hdmedi/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kusitms.hdmedi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
public class AppConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}

68 changes: 68 additions & 0 deletions src/main/java/kusitms/hdmedi/config/JwtTokenFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package kusitms.hdmedi.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kusitms.hdmedi.domain.user.User;
import kusitms.hdmedi.service.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

private final UserService userService;
private final String secretKey;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

// authorizationHeader 비어있으면 로그인 X
if(authorizationHeader == null) {
filterChain.doFilter(request, response);
return;
}

// authorizationHeader 값이 'Bearer '로 시작 안 하면 X
if(!authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}

// 전송받은 값에서 'Bearer ' 뒷부분(JWT) 추출
String token = authorizationHeader.split(" ")[1];

// JWT 만료 시 인증 X
if(JwtTokenUtil.isExpired(token, secretKey)) {
filterChain.doFilter(request, response);
return;
}

// JWT loginId 추출
String loginId = JwtTokenUtil.getLoginId(token, secretKey).get();

// 추출한 loginId 로 User 찾아오기
User loginUser = userService.getLoginUserByLoginId(loginId);

// loginUser 정보로 UsernamePasswordAuthenticationToken 발급
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUser.getLoginId(), null, List.of(new SimpleGrantedAuthority(loginUser.getRole().name())));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// 권한 부여
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}

}

69 changes: 69 additions & 0 deletions src/main/java/kusitms/hdmedi/config/JwtTokenUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package kusitms.hdmedi.config;


import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;

import java.util.Date;
import java.util.Optional;

@Slf4j
public class JwtTokenUtil {

private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";

private static final String ID_CLAIM = "loginId";

// JWT 발급
public static String createToken(String loginId, String key, long expireTimeMs) {

Date now = new Date();
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + expireTimeMs)) // 토큰 만료 시간 설정
.withClaim(ID_CLAIM, loginId)
.sign(Algorithm.HMAC512(key));
}

// Claims 속 loginId 꺼내기
public static Optional<String> getLoginId(String token, String secretKey) {
try {
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(token)
.getClaim(ID_CLAIM)
.asString());
} catch (Exception e) {
log.error("토큰이 유효하지 않습니다.");
return Optional.empty();
}
}

// 토큰에서 만료 시간을 얻고, 현재 시간과 비교하여 토큰이 만료되었는지 확인
public static boolean isExpired(String token, String secretKey) {
try {
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(token);
return jwt.getExpiresAt().before(new Date());
} catch (Exception e) {
return true; // 토큰이 유효하지 않거나 파싱 중 오류가 발생하면 만료된 것으로 처리
}
}

// 토큰을 파싱하여 클레임을 얻는 메소드
public static Optional<Claim> getClaim(String token, String claimName, String secretKey) {
try {
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(token);
return Optional.ofNullable(jwt.getClaim(claimName));
} catch (JWTDecodeException e) {
return Optional.empty(); // 토큰이 유효하지 않거나 파싱 중 오류가 발생하면 빈 Optional 반환
}
}
}
87 changes: 87 additions & 0 deletions src/main/java/kusitms/hdmedi/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package kusitms.hdmedi.config;

import kusitms.hdmedi.domain.user.UserRole;
import kusitms.hdmedi.service.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;

import java.util.Arrays;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
private static String secretKey = "hdmedi-team-b";

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector);
http
.formLogin(formLogin -> formLogin.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.csrf(csrf -> csrf.disable())
.cors(withDefaults())
.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(request ->
request.requestMatchers(mvcMatcherBuilder.pattern("/auth/login")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/css/**")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/js/**")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/images/**")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/error")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/favicon.ico")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/swagger-ui/**")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/swagger-resources/**")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/v3/api-docs/**")).permitAll()
.requestMatchers(mvcMatcherBuilder.pattern("/auth/admin/**")).hasAuthority(UserRole.ADMIN.name())
.anyRequest().authenticated())
.addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class);;

return http.build();
}

@Bean
protected CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", getDefaultCorsConfiguration());

return source;
}

// https://hdmedi.site/swagger-ui/index.html#/
private CorsConfiguration getDefaultCorsConfiguration() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(
Arrays.asList("http://localhost:8080", "https://hdmedi.site", "http://localhost:3000"));
configuration.setAllowedHeaders(Arrays.asList("*")); // 모든 header 에 응답을 허용
configuration.setAllowedMethods(Arrays.asList("*")); // 모든 get,post,patch,put,delete 요청 허용
configuration.setAllowedOrigins(Arrays.asList("*")); // 모든 ip 응답을 허용
configuration.setAllowCredentials(true); // 내 서버가 응답할 때 json 을 자바스크립트에서 처리할 수 있게 할지를 설정하는 것
configuration.setMaxAge(3600L);

return configuration;
}

// 비밀번호 암호화
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

}
45 changes: 45 additions & 0 deletions src/main/java/kusitms/hdmedi/controller/user/UserController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package kusitms.hdmedi.controller.user;

import kusitms.hdmedi.dto.request.user.LoginRequest;
import kusitms.hdmedi.dto.response.user.InfoResponse;
import kusitms.hdmedi.dto.response.user.LoginResponse;
import kusitms.hdmedi.exception.base.BaseResponse;
import kusitms.hdmedi.service.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class UserController {
private final UserService userService;

// 로그인
@PostMapping("/login")
public BaseResponse<LoginResponse> webLogin(@RequestBody @Validated LoginRequest req, BindingResult bindingResult) throws Exception {
if (bindingResult.hasErrors()) {
ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
return BaseResponse.onFailure(400, objectError.getDefaultMessage(), null);
}

LoginResponse result = userService.webLogin(req);
return BaseResponse.onSuccess(result);
}

// 유저 정보
@GetMapping("/info")
public BaseResponse<InfoResponse> userInfo(Authentication auth) {

InfoResponse result = userService.getUserInfo(auth.getName());
return BaseResponse.onSuccess(result);
}

@GetMapping("/admin")
public String adminPage() {
return "관리자 페이지 접근 성공";
}
}
34 changes: 34 additions & 0 deletions src/main/java/kusitms/hdmedi/domain/user/User.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package kusitms.hdmedi.domain.user;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Entity
@Getter
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String loginId;

private String password;

private String nickname;

@Enumerated(value = EnumType.STRING)
private UserRole role;

@Builder
public User(Long id, String loginId, String password, String nickname, UserRole role) {
this.id = id;
this.loginId = loginId;
this.password = password;
this.nickname = nickname;
this.role = role;
}
}
5 changes: 5 additions & 0 deletions src/main/java/kusitms/hdmedi/domain/user/UserRole.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kusitms.hdmedi.domain.user;

public enum UserRole {
USER, ADMIN
}
23 changes: 23 additions & 0 deletions src/main/java/kusitms/hdmedi/dto/request/user/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package kusitms.hdmedi.dto.request.user;

import jakarta.validation.constraints.NotBlank;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LoginRequest {
@NotBlank(message = "아이디를 입력해주세요.")

private String loginId;
@NotBlank(message = "비밀번호를 입력해주세요.")
private String password;

@Builder
public LoginRequest(String loginId, String password) {
this.loginId = loginId;
this.password = password;
}
}
21 changes: 21 additions & 0 deletions src/main/java/kusitms/hdmedi/dto/response/user/InfoResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package kusitms.hdmedi.dto.response.user;

import kusitms.hdmedi.domain.user.UserRole;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Getter
public class InfoResponse {

private String loginId;
private String nickname;
private UserRole role;
@Builder
public InfoResponse(String loginId, String nickname, UserRole role) {
this.loginId = loginId;
this.nickname = nickname;
this.role = role;
}
}
Loading

0 comments on commit 3c3e0fa

Please sign in to comment.