Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

//s3
// spring security, thymeleaf
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")

//s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

//Jwt
Expand Down
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();
Comment on lines +45 to +49

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

JWT 토큰의 만료 시간(15분)과 쿠키의 maxAge(30분)가 일치하지 않습니다. 이로 인해 클라이언트는 유효한 쿠키를 가지고 있지만, 서버에서는 만료된 토큰으로 인해 인증에 실패하는 상황이 발생할 수 있습니다. 사용자 경험에 혼란을 줄 수 있으므로 토큰의 만료 시간과 쿠키의 maxAge를 일치시키는 것이 좋습니다. 또한, '15'와 같은 매직 넘버 대신 설정 파일(application.yml)에서 값을 관리하고 @Value 어노테이션으로 주입받아 사용하는 것을 권장합니다.

Suggested change
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();
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(15)).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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

관리자 접속 로그가 사용자의 역할(Role)과 관계없이 저장되고 있습니다. ADMIN이나 SUPER_ADMIN이 아닌 사용자가 로그인을 시도하고 성공하면, 권한 부족으로 리다이렉트되지만 접속 로그는 남게 됩니다. 이는 부정확한 로그를 생성할 수 있습니다. 관리자 접속 로그는 실제 관리자 권한을 가진 사용자가 성공적으로 로그인했을 때만 저장되어야 합니다.

        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
Expand Up @@ -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;
Expand All @@ -25,7 +26,7 @@

@RequiredArgsConstructor
@Component
@Slf4j //???
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

@Value("${jwt.JWT_HEADER}")
Expand All @@ -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) {
Expand Down Expand Up @@ -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); // 🚨 블랙리스트 확인

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

isTokenBlacklisted 메소드에 authHeader 변수를 전달하고 있습니다. resolveToken을 통해 쿠키에서 토큰을 가져온 경우 authHeadernull이 되어 isTokenBlacklisted 내부에서 NullPointerException이 발생합니다. 이 예외는 catch 블록에 잡혀 사용자에게 오류 응답을 보내게 되므로, 쿠키를 통한 모든 인증이 실패하게 됩니다. 이는 심각한 서비스 거부(Denial of Service) 문제입니다. authHeader 대신 resolveToken으로 얻은 accessToken을 사용해야 합니다.

Suggested change
jwtUtils.isTokenBlacklisted(authHeader); // 🚨 블랙리스트 확인
jwtUtils.isTokenBlacklisted("Bearer " + accessToken); // 🚨 블랙리스트 확인

} catch (Exception e) {
Expand All @@ -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); // 토큰 만료 검증

Expand All @@ -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
Expand Up @@ -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
Expand All @@ -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")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

로그인 폼에서 사용하는 파라미터 이름이 email로 설정되어 있지만, 실제로는 사용자 ID(username)를 받는 것으로 보입니다. loginForm.html에서도 <label for="email">ID</label>로 되어 있어 혼란을 야기합니다. UserDetailsService 구현에서도 username을 기준으로 사용자를 조회하고 있으므로, 파라미터 이름을 username으로 통일하여 코드의 명확성을 높이는 것이 좋겠습니다. SecurityConfig와 함께 loginForm.html<input> 태그 name 속성도 username으로 변경해야 합니다.

Suggested change
.usernameParameter("email")
.usernameParameter("username")

.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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package TtattaBackend.ttatta.config.security;

import TtattaBackend.ttatta.domain.enums.UserRole;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;

@Slf4j
Expand All @@ -22,4 +24,19 @@ public static Long getCurrentUserId() {

return Long.parseLong(authentication.getName());
}

public static UserRole getCurrentUserRole() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication == null || authentication.getAuthorities() == null || !authentication.isAuthenticated()) {
throw new RuntimeException("Security Context 에 인증 정보가 없습니다.");
}
UserRole role = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) // ROLE_ADMIN
.map(r -> r.replaceFirst("^ROLE_", "")) // ADMIN
.map(UserRole::valueOf)
.findFirst()
.orElse(null);
return role;
}
}
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
Expand Up @@ -3,6 +3,7 @@
import TtattaBackend.ttatta.domain.Users;
import TtattaBackend.ttatta.domain.enums.IsAvailable;
import TtattaBackend.ttatta.domain.enums.LoginType;
import TtattaBackend.ttatta.domain.enums.UserRole;
import TtattaBackend.ttatta.domain.enums.UserStatus;
import TtattaBackend.ttatta.web.dto.UserRequestDTO;
import TtattaBackend.ttatta.web.dto.UserResponseDTO;
Expand All @@ -18,6 +19,7 @@ public static Users toUsers(UserRequestDTO.SignUpRequestDTO request) {
.username(request.getUsername())
.loginType(LoginType.REGULAR)
.diaryCategoriesList(new ArrayList<>())
.role(UserRole.USER)
.build();
}

Expand All @@ -31,6 +33,7 @@ public static Users toKakaoUsers(String sub) {
.diaryCategoriesList(new ArrayList<>())
.providerId(sub)
.loginType(LoginType.KAKAO)
.role(UserRole.USER)
.build();
}

Expand Down
25 changes: 25 additions & 0 deletions src/main/java/TtattaBackend/ttatta/domain/LocationAccessLogs.java
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; // 접근권한자 식별 정보
}
33 changes: 33 additions & 0 deletions src/main/java/TtattaBackend/ttatta/domain/LocationLogs.java
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; // 제공받는 자
}
5 changes: 5 additions & 0 deletions src/main/java/TtattaBackend/ttatta/domain/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import TtattaBackend.ttatta.domain.common.BaseEntity;
import TtattaBackend.ttatta.domain.enums.Gender;
import TtattaBackend.ttatta.domain.enums.LoginType;
import TtattaBackend.ttatta.domain.enums.UserRole;
import TtattaBackend.ttatta.domain.enums.UserStatus;
import TtattaBackend.ttatta.domain.mapping.OwnedItems;
import jakarta.persistence.*;
Expand Down Expand Up @@ -71,6 +72,10 @@ public class Users extends BaseEntity {
@Column(columnDefinition = "TEXT")
private String fcmToken;

@Enumerated(EnumType.STRING)
@Column(columnDefinition = "VARCHAR(11)")
private UserRole role;

// 로그인 관련
// private LocalDateTime lastLogin;

Expand Down
5 changes: 5 additions & 0 deletions src/main/java/TtattaBackend/ttatta/domain/enums/UserRole.java
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
}
Loading