-
Notifications
You must be signed in to change notification settings - Fork 2
[Feat] 사용자 위치 정보 접근 로그 저장 및 관리자 페이지(위치정보시스템) 구현 #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0b70a96
af6fc4c
a27291c
826d968
a2867dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package TtattaBackend.ttatta.config.security; | ||
|
|
||
| import TtattaBackend.ttatta.apiPayload.exception.handler.ExceptionHandler; | ||
| import TtattaBackend.ttatta.domain.LocationAccessLogs; | ||
| import TtattaBackend.ttatta.domain.Users; | ||
| import TtattaBackend.ttatta.domain.enums.UserRole; | ||
| import TtattaBackend.ttatta.jwt.JwtUtils; | ||
| import TtattaBackend.ttatta.repository.LocationAccessLogRepository; | ||
| import TtattaBackend.ttatta.repository.UserRepository; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.security.core.Authentication; | ||
| import org.springframework.security.core.userdetails.UsernameNotFoundException; | ||
| import org.springframework.security.web.authentication.AuthenticationSuccessHandler; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
|
|
||
| import static TtattaBackend.ttatta.apiPayload.code.status.ErrorStatus.USER_NOT_FOUND; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final JwtUtils jwtUtils; | ||
| private final LocationAccessLogRepository locationAccessLogRepository; | ||
|
|
||
| @Override | ||
| public void onAuthenticationSuccess( | ||
| HttpServletRequest request, | ||
| HttpServletResponse response, | ||
| Authentication authentication | ||
| ) throws IOException { | ||
| // 로그인 성공 후 처리할 로직을 여기에 작성합니다. | ||
| // User의 Role을 포함한 JWT 토큰 생성 | ||
| Users getUser = userRepository.findByUsername(authentication.getName()) | ||
| .orElseThrow(() -> new UsernameNotFoundException("해당 아이디를 가진 유저가 존재하지 않습니다: " + authentication.getName())); | ||
| Map<String, Object> valueMap = new HashMap<>(); | ||
| valueMap.put("userId", getUser.getId()); | ||
| valueMap.put("role", getUser.getRole()); | ||
| String accessToken = jwtUtils.generateToken(valueMap, 15); | ||
| // JWT 토큰을 HTTP 응답 헤더에 추가 | ||
| var cookie = org.springframework.http.ResponseCookie.from("ACCESS_TOKEN", accessToken) | ||
| .httpOnly(true).secure(true).sameSite("Lax").path("/") | ||
| .maxAge(java.time.Duration.ofMinutes(30)).build(); | ||
| response.addHeader(org.springframework.http.HttpHeaders.SET_COOKIE, cookie.toString()); | ||
| if (getUser.getRole().equals(UserRole.ADMIN)) response.sendRedirect("/admin/location-log"); | ||
| else if (getUser.getRole().equals(UserRole.SUPER_ADMIN)) response.sendRedirect("/super/admin/home"); | ||
| else response.sendRedirect("/admin/login?error=insufficient_role"); // 관리자 권한이 없는 경우: 로그인 페이지로 재이동 (사유 전달) | ||
| // 관리자 접속 로그 저장 | ||
| locationAccessLogRepository.save(LocationAccessLogs.builder() | ||
| .adminId(getUser.getId().toString()) | ||
| .build()); | ||
|
Comment on lines
+51
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 관리자 접속 로그가 사용자의 역할(Role)과 관계없이 저장되고 있습니다. if (getUser.getRole().equals(UserRole.ADMIN) || getUser.getRole().equals(UserRole.SUPER_ADMIN)) {
// 관리자 접속 로그 저장
locationAccessLogRepository.save(LocationAccessLogs.builder()
.adminId(getUser.getId().toString())
.build());
if (getUser.getRole().equals(UserRole.ADMIN)) {
response.sendRedirect("/admin/location-log");
} else { // SUPER_ADMIN
response.sendRedirect("/super/admin/home");
}
} else {
response.sendRedirect("/admin/login?error=insufficient_role"); // 관리자 권ahan이 없는 경우: 로그인 페이지로 재이동 (사유 전달)
} |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |||||
| import io.jsonwebtoken.Claims; | ||||||
| import jakarta.servlet.FilterChain; | ||||||
| import jakarta.servlet.ServletException; | ||||||
| import jakarta.servlet.http.Cookie; | ||||||
| import jakarta.servlet.http.HttpServletRequest; | ||||||
| import jakarta.servlet.http.HttpServletResponse; | ||||||
| import lombok.RequiredArgsConstructor; | ||||||
|
|
@@ -25,7 +26,7 @@ | |||||
|
|
||||||
| @RequiredArgsConstructor | ||||||
| @Component | ||||||
| @Slf4j //??? | ||||||
| @Slf4j | ||||||
| public class JwtAuthenticationFilter extends OncePerRequestFilter { | ||||||
|
|
||||||
| @Value("${jwt.JWT_HEADER}") | ||||||
|
|
@@ -42,11 +43,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { | |||||
| "/items", | ||||||
| "/users/verificate/kakao", | ||||||
| "/users/admin/**", | ||||||
| "/admin/login", | ||||||
| "/loginProc" | ||||||
| // "/refresh", "/", | ||||||
| // "/index.html" | ||||||
| }; | ||||||
| private final JwtUtils jwtUtils; | ||||||
| private final RedisTemplate<String, String> redisTemplate; | ||||||
| private final String cookieName = "ACCESS_TOKEN"; | ||||||
|
|
||||||
| private static void checkAuthorizationHeader(String header) { | ||||||
| if(header == null) { | ||||||
|
|
@@ -104,8 +108,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse | |||||
|
|
||||||
| try { | ||||||
| log.info("------------------------------------------------------"); | ||||||
| checkAuthorizationHeader(authHeader); // header 가 올바른 형식인지 체크 | ||||||
| String accessToken = JwtUtils.getTokenFromHeader(authHeader); | ||||||
| // 타임리프 페이지 인가 처리 | ||||||
| String accessToken = resolveToken(request); | ||||||
| jwtUtils.validateToken(accessToken); // 토큰 검증 | ||||||
| jwtUtils.isTokenBlacklisted(authHeader); // 🚨 블랙리스트 확인 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } catch (Exception e) { | ||||||
|
|
@@ -129,8 +133,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse | |||||
| log.info("--------------------------- JwtVerifyFilter ---------------------------"); | ||||||
|
|
||||||
| try { | ||||||
| checkAuthorizationHeader(authHeader); // header 가 올바른 형식인지 체크 | ||||||
| String token = JwtUtils.getTokenFromHeader(authHeader); | ||||||
| // 타임리프 페이지 인가 처리 | ||||||
| String token = resolveToken(request); | ||||||
|
|
||||||
| jwtUtils.validateToken(token); // 토큰 검증 | ||||||
| jwtUtils.isExpired(token); // 토큰 만료 검증 | ||||||
|
|
||||||
|
|
@@ -154,4 +159,23 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse | |||||
| printWriter.close(); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private String resolveToken(HttpServletRequest request) { | ||||||
| // 1) Authorization 헤더: Bearer | ||||||
| String authHeader = request.getHeader(jwtHeader); | ||||||
| if (authHeader != null && authHeader.startsWith("Bearer ")) { | ||||||
| checkAuthorizationHeader(authHeader); // header 가 올바른 형식인지 체크 | ||||||
| return JwtUtils.getTokenFromHeader(authHeader); | ||||||
| } | ||||||
| // 2) 쿠키: ACCESS_TOKEN | ||||||
| if (request.getCookies() != null) { | ||||||
| for (Cookie c : request.getCookies()) { | ||||||
| if (cookieName.equals(c.getName())) { | ||||||
| // 쿠키에 바로 토큰을 담은 경우 | ||||||
| return c.getValue(); | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| return null; | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |||||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||||||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||||||
| import org.springframework.security.web.SecurityFilterChain; | ||||||
| import org.springframework.security.web.authentication.AuthenticationSuccessHandler; | ||||||
| import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||||||
|
|
||||||
| @EnableWebSecurity | ||||||
|
|
@@ -18,37 +19,54 @@ | |||||
| public class SecurityConfig { | ||||||
|
|
||||||
| private final JwtAuthenticationFilter jwtAuthenticationFilter; | ||||||
| private final AuthenticationSuccessHandler customAuthenticationSuccessHandler; | ||||||
|
|
||||||
| @Bean | ||||||
| public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { | ||||||
| http | ||||||
| .csrf(AbstractHttpConfigurer::disable) // 추가해주어야함. | ||||||
| // 폼 로그인 비활성화 | ||||||
| .formLogin(AbstractHttpConfigurer::disable) | ||||||
| .formLogin( (formLogin) -> formLogin | ||||||
| .loginPage("/admin/login") | ||||||
| .loginProcessingUrl("/loginProc") | ||||||
| .usernameParameter("email") | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 로그인 폼에서 사용하는 파라미터 이름이
Suggested change
|
||||||
| .passwordParameter("password") | ||||||
| .defaultSuccessUrl("/admin") | ||||||
| .successHandler(customAuthenticationSuccessHandler) | ||||||
| // .failureHandler(customFailureHandler) | ||||||
| .permitAll() | ||||||
| ) | ||||||
| .logout( (logout) -> logout | ||||||
| .logoutUrl("/logoutProc") | ||||||
| .logoutSuccessUrl("/admin/login") | ||||||
| .permitAll() | ||||||
| ) | ||||||
| // HTTP Basic 인증 비활성화 | ||||||
| .httpBasic(AbstractHttpConfigurer::disable) | ||||||
| .sessionManagement(session -> session | ||||||
| .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션을 Stateless로 설정 | ||||||
| .sessionCreationPolicy(SessionCreationPolicy.STATELESS) | ||||||
| ) // 세션을 Stateless로 설정 | ||||||
| .authorizeHttpRequests((requests) -> requests | ||||||
| .requestMatchers("/**").permitAll()) | ||||||
| .requestMatchers("/admin/**").hasAnyRole("SUPER_ADMIN", "ADMIN") // == hasAuthority("ROLE_ADMIN") | ||||||
| .requestMatchers("/super/admin/**").hasRole("SUPER_ADMIN") // == hasAuthority("ROLE_ADMIN") | ||||||
| .requestMatchers("/**").permitAll() | ||||||
| // .requestMatchers( | ||||||
| // "/users/signup", | ||||||
| // "/users/signup/**", | ||||||
| // "/users/signin", | ||||||
| // "/users/signin/**", | ||||||
| // "/users/testuser", | ||||||
| // "/users/find/**", | ||||||
| // "/swagger-ui/**", | ||||||
| // "/v3/**", | ||||||
| // "/items", | ||||||
| // "/users/verificate/kakao", | ||||||
| // "/users/admin/**", | ||||||
| // "/admin/login", | ||||||
| // "/loginProc" | ||||||
| // ).permitAll() | ||||||
| ) | ||||||
| .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); | ||||||
|
|
||||||
| // .authorizeHttpRequests((requests) -> requests | ||||||
| // .requestMatchers("/", "/home", "/signup", "/css/**").permitAll() | ||||||
| // .requestMatchers("/admin/**").hasRole("ADMIN") | ||||||
| // .anyRequest().authenticated() | ||||||
| // ) | ||||||
| // .formLogin((form) -> form | ||||||
| // .loginPage("/login") | ||||||
| // .defaultSuccessUrl("/home", true) | ||||||
| // .permitAll() | ||||||
| // ) | ||||||
| // .logout((logout) -> logout | ||||||
| // .logoutUrl("/logout") | ||||||
| // .logoutSuccessUrl("/login?logout") | ||||||
| // .permitAll() | ||||||
| // ); | ||||||
|
|
||||||
| return http.build(); | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package TtattaBackend.ttatta.converter; | ||
|
|
||
| import TtattaBackend.ttatta.domain.LocationLogs; | ||
| import TtattaBackend.ttatta.domain.Users; | ||
|
|
||
| public class LocationLogConverter { | ||
|
|
||
| public static LocationLogs toLocationsLogs(Users user, String provisionalService, String recipient) { | ||
| return LocationLogs.builder() | ||
| .target(user.getId().toString()) | ||
| .provisionalService(provisionalService) | ||
| .recipient(recipient) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package TtattaBackend.ttatta.domain; | ||
|
|
||
|
|
||
| import TtattaBackend.ttatta.domain.common.BaseEntity; | ||
| import jakarta.persistence.*; | ||
| import lombok.*; | ||
| import org.hibernate.annotations.DynamicInsert; | ||
| import org.hibernate.annotations.DynamicUpdate; | ||
|
|
||
| @Entity | ||
| @Getter | ||
| @DynamicUpdate | ||
| @DynamicInsert | ||
| @Builder | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @AllArgsConstructor | ||
| public class LocationAccessLogs extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false, length = 20) | ||
| private String adminId; // 접근권한자 식별 정보 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package TtattaBackend.ttatta.domain; | ||
|
|
||
| import TtattaBackend.ttatta.domain.common.BaseEntity; | ||
| import jakarta.persistence.*; | ||
| import lombok.*; | ||
| import org.hibernate.annotations.DynamicInsert; | ||
| import org.hibernate.annotations.DynamicUpdate; | ||
|
|
||
| @Entity | ||
| @Getter | ||
| @DynamicUpdate | ||
| @DynamicInsert | ||
| @Builder | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @AllArgsConstructor | ||
| public class LocationLogs extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false, length = 50) | ||
| private String target; // 대상 | ||
|
|
||
| @Column(nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'Android'") | ||
| private String acquisitionPath; // 취득 경로 | ||
|
|
||
| @Column(nullable = false, length = 50) | ||
| private String provisionalService; // 제공 서비스 | ||
|
|
||
| @Column(length = 20) | ||
| private String recipient; // 제공받는 자 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package TtattaBackend.ttatta.domain.enums; | ||
|
|
||
| public enum UserRole { | ||
| SUPER_ADMIN, ADMIN, USER | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JWT 토큰의 만료 시간(15분)과 쿠키의
maxAge(30분)가 일치하지 않습니다. 이로 인해 클라이언트는 유효한 쿠키를 가지고 있지만, 서버에서는 만료된 토큰으로 인해 인증에 실패하는 상황이 발생할 수 있습니다. 사용자 경험에 혼란을 줄 수 있으므로 토큰의 만료 시간과 쿠키의maxAge를 일치시키는 것이 좋습니다. 또한, '15'와 같은 매직 넘버 대신 설정 파일(application.yml)에서 값을 관리하고@Value어노테이션으로 주입받아 사용하는 것을 권장합니다.