Skip to content
7 changes: 3 additions & 4 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ jobs:
- name: Locate and Prepare JAR artifact
id: get_jar_path
run: |
# build.gradle에서 jar { enabled = false } 설정 후,
# 기본적으로 생성되는 실행 가능한 JAR 파일명을 정확히 찾음 (예: artifactId-version.jar)
# JoyCrew의 경우 'backend-0.0.1-SNAPSHOT.jar' 형태일 가능성이 높음.
JAR_FILE=$(find build/libs -name "*.jar" | head -n 1)

if [ -z "$JAR_FILE" ]; then
Expand Down Expand Up @@ -62,7 +59,9 @@ jobs:

script: |
set -eux


export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}"

sudo systemctl stop joycrew-backend || true

DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs"
Expand Down
49 changes: 43 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,62 @@ repositories {
mavenCentral()
}

sourceSets {
main {
java {
srcDirs = ['src/main/java']
}
resources {
srcDirs = ['src/main/resources']
}
}
test {
java {
srcDirs = ['src/test/java']
}
resources {
srcDirs = ['src/test/resources']
}
}
}

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 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.security:spring-security-crypto'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.security:spring-security-test'

compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'

annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

jar {
enabled = false
}

tasks.named('test') {
useJUnitPlatform()
}

jar {
enabled = false
}
filter {
includeTestsMatching "com.joycrew.backend.service.*Test"
includeTestsMatching "com.joycrew.backend.repository.*Test"
includeTestsMatching "com.joycrew.backend.controller.*Test"
}

testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
showStandardStreams = true
}
}
111 changes: 111 additions & 0 deletions src/main/java/com/joycrew/backend/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.joycrew.backend.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.joycrew.backend.repository.EmployeeRepository;
import com.joycrew.backend.security.JwtAuthenticationFilter;
import com.joycrew.backend.security.JwtUtil;
import com.joycrew.backend.security.EmployeeDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
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.web.SecurityFilterChain;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.UserDetailsService;

import java.util.Map;


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtUtil jwtUtil;
private final EmployeeRepository employeeRepository;
private final ObjectMapper objectMapper;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.disable()))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/h2-console/**",
"/api/auth/login",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");

// JSON 형식으로 에러 메시지 생성
String jsonResponse = objectMapper.writeValueAsString(
Map.of("message", "로그인이 필요합니다.")
);

response.getWriter().write(jsonResponse);
})
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService()),
UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form.disable())
.httpBasic(httpBasic -> httpBasic.disable());

return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("http://localhost:3000");
config.addAllowedOriginPattern("https://joycrew.co.kr");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}

@Bean
public UserDetailsService userDetailsService() {
return new EmployeeDetailsService(employeeRepository);
}

@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
}
31 changes: 31 additions & 0 deletions src/main/java/com/joycrew/backend/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.joycrew.backend.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {

@Bean
public OpenAPI openAPI() {
final String securitySchemeName = "Authorization";

return new OpenAPI()
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
.components(new Components().addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.name(securitySchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.info(new Info()
.title("JoyCrew API")
.version("v1.0.0")
.description("JoyCrew 백엔드 API 명세서입니다."));
}
}
70 changes: 70 additions & 0 deletions src/main/java/com/joycrew/backend/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.joycrew.backend.controller;

import com.joycrew.backend.dto.LoginRequest;
import com.joycrew.backend.dto.LoginResponse;
import com.joycrew.backend.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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Tag(name = "인증", description = "로그인 관련 API")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthService authService;

@Operation(summary = "로그인", description = "이메일과 비밀번호를 이용해 JWT 토큰과 사용자 정보를 반환합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그인 성공",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = LoginResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (이메일 또는 비밀번호 오류)",
content = @Content(mediaType = "application/json",
schema = @Schema(example = "{\"accessToken\": \"\", \"message\": \"이메일 또는 비밀번호가 올바르지 않습니다.\"}")))
})
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody @Valid LoginRequest request) {
try {
return ResponseEntity.ok(authService.login(request));
} catch (UsernameNotFoundException | BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(LoginResponse.builder()
.accessToken("")
.message(e.getMessage())
.build());
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(LoginResponse.builder()
.accessToken("")
.message("서버 오류가 발생했습니다.")
.build());
}
}

@Operation(summary = "로그아웃", description = "사용자의 로그아웃 요청을 처리합니다. 클라이언트는 이 응답을 받은 후 토큰을 삭제해야 합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공",
content = @Content(mediaType = "application/json",
schema = @Schema(example = "{\"message\": \"로그아웃 되었습니다.\"}")))
})
@PostMapping("/logout")
public ResponseEntity<Map<String, String>> logout(HttpServletRequest request) {
authService.logout(request);
return ResponseEntity.ok(Map.of("message", "로그아웃 되었습니다."));
}
}
74 changes: 74 additions & 0 deletions src/main/java/com/joycrew/backend/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.joycrew.backend.controller;

import com.joycrew.backend.dto.UserProfileResponse;
import com.joycrew.backend.entity.Employee;
import com.joycrew.backend.entity.Wallet;
import com.joycrew.backend.repository.EmployeeRepository;
import com.joycrew.backend.repository.WalletRepository;
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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.Optional;

@Tag(name = "사용자", description = "사용자 정보 관련 API")
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {

private final EmployeeRepository employeeRepository;
private final WalletRepository walletRepository;

@Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = UserProfileResponse.class))),
@ApiResponse(responseCode = "401", description = "토큰 없음 또는 유효하지 않음",
content = @Content(mediaType = "application/json",
schema = @Schema(example = "{\"message\": \"유효하지 않은 토큰입니다.\"}")))
})
@GetMapping("/profile")
public ResponseEntity<?> getProfile(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("message", "유효하지 않은 토큰입니다."));
}

String userEmail = authentication.getName();

Employee employee = employeeRepository.findByEmail(userEmail)
.orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다."));

Optional<Wallet> walletOptional = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId());
int totalBalance = 0;
int giftableBalance = 0;
if (walletOptional.isPresent()) {
Wallet wallet = walletOptional.get();
totalBalance = wallet.getBalance();
giftableBalance = wallet.getGiftablePoint();
}

UserProfileResponse response = UserProfileResponse.builder()
.employeeId(employee.getEmployeeId())
.name(employee.getEmployeeName())
.email(employee.getEmail())
.role(employee.getRole())
.department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) // 부서 이름 추가
.position(employee.getPosition())
.totalBalance(totalBalance)
.giftableBalance(giftableBalance)
.build();

return ResponseEntity.ok(response);
}
}
Loading