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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ jobs:
gradle-home-cache-cleanup: true

- name: Create dummy .env for CI
run: echo "# ci dummy" > .env
env:
AUDIENCE_SECRET: ${{ secrets.JWT_AUDIENCE }}
run: |
AUDIENCE_VALUE="${AUDIENCE_SECRET:-ci-audience}"
cat > .env <<EOF
# ci dummy
JWT_AUDIENCE=${AUDIENCE_VALUE}
EOF

- name: Gradle build (skip tests)
id: assemble
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ jobs:
DB_USERNAME=${{ secrets.DB_USERNAME }}
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_REDIRECT_URI=${{ secrets.GOOGLE_REDIRECT_URI }}
GOOGLE_ISSUER=${{ secrets.GOOGLE_ISSUER }}
SELF_ISSUER=${{ secrets.SELF_ISSUER }}
JWT_AUDIENCE=${{ secrets.JWT_AUDIENCE }}
REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE || 'false' }}
REFRESH_COOKIE_SAME_SITE=${{ secrets.REFRESH_COOKIE_SAME_SITE || 'Lax' }}
REFRESH_COOKIE_DOMAIN=${{ secrets.REFRESH_COOKIE_DOMAIN }}
Comment on lines +42 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, search for ACCESS_COOKIE references across workflows
rg -n "ACCESS_COOKIE" .github/workflows/

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 53


🏁 Script executed:

# Check the full deploy-dev.yml file to see all env vars defined
cat -n .github/workflows/deploy-dev.yml

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 3598


🏁 Script executed:

# Check deploy-prod.yml for comparison
cat -n .github/workflows/deploy-prod.yml

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 3669


🏁 Script executed:

# Look for application-prod.yml to see what variables it references
fd "application-prod.yml" --type f

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 110


🏁 Script executed:

# Check if application-prod.yml references ACCESS_COOKIE_* variables
cat src/main/resources/application-prod.yml

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 2010


ACCESS_COOKIE_* 환경 변수가 두 배포 워크플로우에서 누락되었습니다.

application-prod.yml에서 ACCESS_COOKIE_SECURE, ACCESS_COOKIE_SAME_SITE, ACCESS_COOKIE_DOMAIN을 참조하고 있으나, deploy-dev.ymldeploy-prod.yml 모두에서 .env에 포함되지 않았습니다. REFRESH_COOKIE_* 환경 변수는 명시적으로 추가되었지만, 동일한 수준의 쿠키 설정인 ACCESS_COOKIE_*는 누락되어 있어 설정 관리의 일관성이 부족합니다. 애플리케이션은 기본값(secure: false, same-site: Lax)으로 정상 작동하겠지만, 명시적 관리를 위해 두 워크플로우에 모두 ACCESS_COOKIE_SECURE, ACCESS_COOKIE_SAME_SITE, ACCESS_COOKIE_DOMAIN을 추가해 주세요.

🤖 Prompt for AI Agents
In @.github/workflows/deploy-dev.yml around lines 42 - 45, Add the missing
ACCESS_COOKIE_* environment variables to the deploy workflows: ensure
ACCESS_COOKIE_SECURE, ACCESS_COOKIE_SAME_SITE, and ACCESS_COOKIE_DOMAIN are
exported into the environment block (same style as REFRESH_COOKIE_SECURE,
REFRESH_COOKIE_SAME_SITE, REFRESH_COOKIE_DOMAIN) in both deploy-dev.yml and
deploy-prod.yml so that application-prod.yml can read explicit values; use the
corresponding secrets (e.g., secrets.ACCESS_COOKIE_SECURE,
secrets.ACCESS_COOKIE_SAME_SITE, secrets.ACCESS_COOKIE_DOMAIN) and fallbacks
consistent with REFRESH_COOKIE_* conventions.

SECRET_KEY=${{ secrets.SECRET_KEY }}
AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ jobs:
DB_USERNAME=${{ secrets.DB_USERNAME }}
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_REDIRECT_URI=${{ secrets.GOOGLE_REDIRECT_URI }}
GOOGLE_ISSUER=${{ secrets.GOOGLE_ISSUER }}
SELF_ISSUER=${{ secrets.SELF_ISSUER }}
JWT_AUDIENCE=${{ secrets.JWT_AUDIENCE }}
REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE || 'false' }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

프로덕션 환경에서 REFRESH_COOKIE_SECURE의 기본값이 false입니다 — 보안 위험.

리프레시 토큰 쿠키가 Secure 플래그 없이 전송되면 HTTP를 통해 탈취될 수 있습니다. 프로덕션 배포에서는 기본값을 true로 설정해야 합니다.

🔒 제안
-          REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE || 'false' }}
+          REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE || 'true' }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE || 'false' }}
REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE || 'true' }}
🤖 Prompt for AI Agents
In @.github/workflows/deploy-prod.yml at line 46, The REFRESH_COOKIE_SECURE
environment variable currently defaults to 'false' in the deploy workflow;
change its default to 'true' so production deployments set the refresh-token
cookie Secure flag by default, while preserving the ability for secrets to
override it (i.e., use REFRESH_COOKIE_SECURE=${{ secrets.REFRESH_COOKIE_SECURE
|| 'true' }}). Update the workflow entry that references REFRESH_COOKIE_SECURE
to use the new default and ensure any downstream logic that reads
REFRESH_COOKIE_SECURE treats the value as a boolean/string accordingly.

REFRESH_COOKIE_SAME_SITE=${{ secrets.REFRESH_COOKIE_SAME_SITE || 'Lax' }}
REFRESH_COOKIE_DOMAIN=${{ secrets.REFRESH_COOKIE_DOMAIN }}
SECRET_KEY=${{ secrets.SECRET_KEY }}
AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
Expand Down
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ ext {
dotenvVersion = '5.2.2'
querydslVersion = '6.10.1'
jjwtVersion = '0.13.0'
googleApiClientVersion = '2.6.0'
}

/* ===== Vulnerability Pins (정확한 CVE 기반 대응) ===== */
Expand Down Expand Up @@ -104,7 +105,7 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

//Google API Client
implementation 'com.google.api-client:google-api-client:2.2.0'
implementation "com.google.api-client:google-api-client:${googleApiClientVersion}"
}

dependencyManagement {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
package inha.gdgoc.domain.user.controller;
package inha.gdgoc.domain.admin.user.controller;

import inha.gdgoc.domain.user.dto.request.UpdateRoleRequest;
import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest;
import inha.gdgoc.domain.user.dto.response.UserSummaryResponse;
import inha.gdgoc.domain.user.enums.TeamType;
import inha.gdgoc.domain.user.enums.UserRole;
import inha.gdgoc.domain.user.service.UserAdminService;
import inha.gdgoc.domain.admin.user.dto.request.UpdateRoleRequest;
import inha.gdgoc.domain.admin.user.dto.request.UpdateUserRoleTeamRequest;
import inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse;
import inha.gdgoc.domain.admin.user.service.UserAdminService;
import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails;
import inha.gdgoc.global.dto.response.ApiResponse;
import inha.gdgoc.global.dto.response.PageMeta;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
Expand All @@ -28,7 +36,7 @@ public class UserAdminController {
"@accessGuard.check(authentication,"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
+ "T(inha.gdgoc.domain.user.enums.UserRole).LEAD),"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of("
+ "T(inha.gdgoc.domain.user.enums.UserRole).CORE,"
+ " T(inha.gdgoc.domain.user.enums.TeamType).HR))";
private static final String LEAD_OR_HIGHER_RULE =
Expand All @@ -38,7 +46,6 @@ public class UserAdminController {

private final UserAdminService userAdminService;

// q(검색) + role/team(필터) + pageable
@Operation(summary = "사용자 요약 목록 조회", security = {@SecurityRequirement(name = "BearerAuth")})
@PreAuthorize(LEAD_OR_HR_RULE)
@GetMapping
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package inha.gdgoc.domain.user.dto.request;
package inha.gdgoc.domain.admin.user.dto.request;

import inha.gdgoc.domain.user.enums.UserRole;
import jakarta.validation.constraints.NotNull;

public record UpdateRoleRequest(
@NotNull UserRole role
) {}
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package inha.gdgoc.domain.admin.user.dto.request;

import inha.gdgoc.domain.user.enums.TeamType;
import inha.gdgoc.domain.user.enums.UserRole;

public record UpdateUserRoleTeamRequest(
UserRole role,
TeamType team
) {}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package inha.gdgoc.domain.user.dto.response;
package inha.gdgoc.domain.admin.user.dto.response;

import inha.gdgoc.domain.user.enums.TeamType;
import inha.gdgoc.domain.user.enums.UserRole;
Expand All @@ -11,4 +11,4 @@ public record UserSummaryResponse(
String email,
UserRole userRole,
TeamType team
) {}
) {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package inha.gdgoc.domain.user.service;
package inha.gdgoc.domain.admin.user.service;

import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest;
import inha.gdgoc.domain.user.dto.response.UserSummaryResponse;
import inha.gdgoc.domain.admin.user.dto.request.UpdateUserRoleTeamRequest;
import inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse;
import inha.gdgoc.domain.user.entity.User;
import inha.gdgoc.domain.user.enums.TeamType;
import inha.gdgoc.domain.user.enums.UserRole;
Expand All @@ -26,8 +26,6 @@ public class UserAdminService {

private final UserRepository userRepository;

/* ======================= 목록 ======================= */

@Transactional(readOnly = true)
public Page<UserSummaryResponse> listUsers(String q, Pageable pageable) {
Pageable fixed = rewriteSort(pageable);
Expand All @@ -36,7 +34,9 @@ public Page<UserSummaryResponse> listUsers(String q, Pageable pageable) {

private Pageable rewriteSort(Pageable pageable) {
Sort original = pageable.getSort();
if (original.isUnsorted()) return pageable;
if (original.isUnsorted()) {
return pageable;
}

Sort composed = Sort.unsorted();
boolean hasUserRoleOrder = false;
Expand All @@ -50,6 +50,7 @@ private Pageable rewriteSort(Pageable pageable) {
" WHEN u.userRole = 'ORGANIZER' THEN 4 " +
" WHEN u.userRole = 'ADMIN' THEN 5 " +
" ELSE -1 END)";

for (Sort.Order o : original) {
String prop = o.getProperty();
Sort.Direction dir = o.getDirection();
Expand All @@ -73,11 +74,10 @@ private Pageable rewriteSort(Pageable pageable) {
composed = composed.and(JpaSort.unsafe(Sort.Direction.DESC, roleRankCase));
composed = composed.and(Sort.by("name").ascending());
}

return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), composed);
}

/* ======================= 수정 ======================= */

@Transactional
public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) {
User editorUser = getEditor(editor);
Expand All @@ -90,10 +90,8 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole);
TeamType requestedTeam = (req.team() != null ? req.team() : target.getTeam());

// 팀 보유 가능한 역할만 팀 허용 (CORE, LEAD)
TeamType newTeam = isTeamAssignableRole(newRole) ? requestedTeam : null;

// 공통: 에디터는 대상의 현재/신규 role보다 엄격히 높아야 함
if (!(editorRole.rank() > targetCurrentRole.rank())) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상위 사용자의 정보는 변경할 수 없습니다.");
}
Expand Down Expand Up @@ -124,7 +122,6 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat
}

if (editor.getTeam() == TeamType.HR) {
// HR-LEAD: 본인 제외 타인지원 팀 변경 가능
if (editorUser.getId().equals(target.getId())) {
if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다.");
Expand Down Expand Up @@ -159,12 +156,13 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use

UserRole current = target.getUserRole();

// HR-CORE 특례: GUEST -> MEMBER
boolean isHrCore = (meRole == UserRole.CORE) && (meTeam == TeamType.HR);
if (isHrCore) {
if (current == UserRole.GUEST && newRole == UserRole.MEMBER) {
target.changeRole(UserRole.MEMBER);
if (!isTeamAssignableRole(UserRole.MEMBER)) target.changeTeam(null);
if (!isTeamAssignableRole(UserRole.MEMBER)) {
target.changeTeam(null);
}
userRepository.save(target);
return;
}
Expand All @@ -178,7 +176,9 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use
}

target.changeRole(newRole);
if (!isTeamAssignableRole(newRole)) target.changeTeam(null);
if (!isTeamAssignableRole(newRole)) {
target.changeTeam(null);
}
userRepository.save(target);
}

Expand All @@ -203,18 +203,22 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) {
}

switch (editorRole) {
case ADMIN -> {}
case ADMIN -> {
}
case ORGANIZER -> {
if (targetRole == UserRole.ADMIN) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 삭제할 수 없습니다.");
}
}
case LEAD -> {
if (editorTeam == null) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다.");
}
if (!(targetRole == UserRole.MEMBER || targetRole == UserRole.CORE)) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 삭제할 수 있습니다.");
}
if (editorTeam != TeamType.HR) {
if (editorTeam == null || targetTeam != editorTeam) {
if (targetTeam != editorTeam) {
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 삭제할 수 없습니다.");
}
}
Expand All @@ -225,19 +229,21 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) {
userRepository.delete(target);
}

private User getEditor(CustomUserDetails editor) {
return userRepository.findById(editor.getUserId())
.orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER));
}

private void targetChange(User target, UserRole newRole, TeamType newTeam) {
target.changeRole(newRole);
if (!isTeamAssignableRole(newRole)) newTeam = null;
if (!isTeamAssignableRole(newRole)) {
newTeam = null;
}
target.changeTeam(newTeam);
userRepository.save(target);
}

private User getEditor(CustomUserDetails editor) {
return userRepository.findById(editor.getUserId())
.orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER));
}

private boolean isTeamAssignableRole(UserRole role) {
return role == UserRole.CORE || role == UserRole.LEAD;
}
}
}
Loading
Loading