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
7 changes: 3 additions & 4 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ on:
types: [completed]
branches: [develop]

concurrency:
group: cd-dev
cancel-in-progress: true

jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
concurrency:
group: cd-dev
cancel-in-progress: false
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/cd-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ on:
types: [completed]
branches: [main]

concurrency:
group: cd-prod
cancel-in-progress: true

jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
concurrency:
group: cd-prod
cancel-in-progress: false
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- 수동 입력 닉네임은 API에서 10자로 제한하지만, 가입 시 생성하는 기본 닉네임은 최대 20자까지 저장한다.
-- 기존 uk_member_nickname 유니크 제약은 컬럼 타입 변경 후에도 유지된다.
ALTER TABLE member
ALTER COLUMN nickname TYPE character varying(20);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- 전국 법정동 검색 데이터는 좌표를 제공하지 않는다.
-- 좌표 기반 법정동 조회는 카카오 API가 담당하므로, 사전 적재 행의 좌표는 NULL을 허용한다.
ALTER TABLE location ALTER COLUMN latitude DROP NOT NULL;
ALTER TABLE location ALTER COLUMN longitude DROP NOT NULL;

-- 같은 데이터셋을 애플리케이션 시작 때마다 다시 적재하지 않기 위한 버전 기록.
CREATE TABLE location_dataset_import (
dataset_version character varying(20) PRIMARY KEY,
source_name character varying(200) NOT NULL,
imported_at timestamp(6) with time zone NOT NULL
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
-- 과거 local 전용 시드가 법정동(B)이 아닌 행정동(H) 코드를 사용했던 두 행을
-- 실제 좌표에 해당하는 법정동 코드로 병합한다. 연결된 제보/즐겨찾기는 보존한다.
DO $$
DECLARE
mapping record;
old_location_id bigint;
new_location_id bigint;
BEGIN
FOR mapping IN
SELECT *
FROM (VALUES
('4113565000', '4113510900', '경기도', '성남시 분당구', '삼평동'),
('2611051000', '2611013900', '부산광역시', '중구', '남포동4가')
) AS mappings(old_code, new_code, sido, sigungu, dong)
LOOP
SELECT id
INTO old_location_id
FROM location
WHERE admin_code = mapping.old_code;

IF old_location_id IS NULL THEN
CONTINUE;
END IF;

SELECT id
INTO new_location_id
FROM location
WHERE admin_code = mapping.new_code;

IF new_location_id IS NULL THEN
UPDATE location
SET admin_code = mapping.new_code,
sido = mapping.sido,
sigungu = mapping.sigungu,
dong = mapping.dong,
updated_at = current_timestamp
WHERE id = old_location_id;
ELSE
UPDATE weather_report
SET location_id = new_location_id
WHERE location_id = old_location_id;

DELETE FROM location_favorite old_favorite
USING location_favorite new_favorite
WHERE old_favorite.location_id = old_location_id
AND new_favorite.location_id = new_location_id
AND old_favorite.member_id = new_favorite.member_id;

UPDATE location_favorite
SET location_id = new_location_id
WHERE location_id = old_location_id;

DELETE FROM location
WHERE id = old_location_id;
END IF;
END LOOP;
END
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 추가 연동은 로그인이 아니므로 실제 로그인 전까지 last_login_at 을 비워 둔다.
ALTER TABLE social_account
ALTER COLUMN last_login_at DROP NOT NULL;
10 changes: 5 additions & 5 deletions auth/src/main/java/com/nalssilog/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ public MeResponse signup(@Valid @RequestBody SignupRequest request,
.orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND));

MemberInfo member = memberClient.registerMember(
new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), null),
request.name(), request.nickname(), request.agreedTerms());
new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), ticket.socialName()),
request.agreedTerms());
TokenPair tokens = authTokenService.issue(
member.id(), member.status(), deviceInfoResolver.resolve(httpRequest));
member.id(), member.status(), ticket.provider(), deviceInfoResolver.resolve(httpRequest));
cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken());
ticketStore.deleteSignup(ticketId);
cookieManager.clearSignupTicketCookie(response);
Expand Down Expand Up @@ -180,8 +180,8 @@ public LinkConsentResponse consentLink(HttpServletRequest request) {
ticketStore.markLinkConsented(ticketId, properties.ticket().ttl());

MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId());
Provider reauthProvider = target.currentProvider() != null
? target.currentProvider()
Provider reauthProvider = target.lastLoginProvider() != null
? target.lastLoginProvider()
: target.connectedProviders().get(0);

return new LinkConsentResponse("/api/auth/link/reauth/" + reauthProvider.name().toLowerCase(Locale.ROOT));
Expand Down
19 changes: 3 additions & 16 deletions auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
package com.nalssilog.auth.api.dto;

import com.nalssilog.member.application.dto.TermsAgreement;
import com.nalssilog.member.domain.Member;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;

/**
* 회원가입 입력. OAuth 정보(provider/이메일 등)는 백엔드 signup 티켓에 있으므로 프론트가 보내지 않는다.
* 회원가입 입력. OAuth 정보와 이름은 백엔드 signup 티켓에 있고, 닉네임은 가입 시 자동 생성한다.
* agreedTerms 는 동의한 약관(종류+버전) 목록이며 필수 약관 누락은 서버가 검증한다.
*/
public record SignupRequest(
@NotBlank(message = "이름을 입력해 주세요.")
@Size(max = 30, message = "이름은 30자 이하여야 합니다.")
String name,

@NotBlank(message = "닉네임을 입력해 주세요.")
@Size(min = Member.NICKNAME_MIN_LENGTH, max = Member.NICKNAME_MAX_LENGTH,
message = "닉네임은 2~10자여야 합니다.")
@Pattern(regexp = Member.NICKNAME_PATTERN, message = "닉네임은 한글·영문·숫자만 쓸 수 있습니다.")
String nickname,

@NotNull(message = "약관 동의 정보가 필요합니다.")
List<TermsAgreement> agreedTerms
List<@Valid @NotNull(message = "약관 동의 항목이 비어 있습니다.") TermsAgreement> agreedTerms
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.nalssilog.common.exception.NalssiLogException;
import com.nalssilog.member.application.dto.MemberInfo;
import com.nalssilog.member.domain.MemberStatus;
import com.nalssilog.member.domain.Provider;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
Expand All @@ -32,8 +33,9 @@ public class AuthTokenService {
private final AuthProperties properties;

/** 새 로그인 세션 발급(새 sessionId + 기기 정보 저장). */
public TokenPair issue(Long memberId, MemberStatus status, DeviceInfo device) {
return issueWithSession(memberId, status, UUID.randomUUID().toString(), Instant.now(), device);
public TokenPair issue(Long memberId, MemberStatus status, Provider provider, DeviceInfo device) {
return issueWithSession(
memberId, status, provider, UUID.randomUUID().toString(), Instant.now(), device);
}

/** rotation: 토큰만 교체하고 기기 정체성(sessionId·loginAt·deviceName)은 유지, lastActiveAt·ip 갱신. */
Expand All @@ -49,7 +51,8 @@ public TokenPair refresh(String refreshToken, DeviceInfo device) {

DeviceInfo preserved = new DeviceInfo(current.deviceName(), device.ip());

return issueWithSession(member.id(), member.status(), current.sessionId(), current.loginAt(), preserved);
return issueWithSession(
member.id(), member.status(), current.provider(), current.sessionId(), current.loginAt(), preserved);
}

public void revoke(String refreshToken) {
Expand All @@ -66,13 +69,13 @@ public void revokeAllSessions(Long memberId) {
refreshTokenStore.deleteAllByMember(memberId);
}

private TokenPair issueWithSession(Long memberId, MemberStatus status, String sessionId,
private TokenPair issueWithSession(Long memberId, MemberStatus status, Provider provider, String sessionId,
Instant loginAt, DeviceInfo device) {
String accessToken = jwtTokenProvider.createAccessToken(memberId, status);
String accessToken = jwtTokenProvider.createAccessToken(memberId, status, provider);
String refreshToken = generateRefreshToken();
String tokenHash = hash(refreshToken);
SessionData session = new SessionData(
tokenHash, sessionId, memberId, device.deviceName(), device.ip(), loginAt, Instant.now());
tokenHash, sessionId, memberId, provider, device.deviceName(), device.ip(), loginAt, Instant.now());

refreshTokenStore.save(tokenHash, session, properties.jwt().refreshTokenTtl());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

public static final String EMAIL_REQUIRED_ERROR = "email_required";

private final MemberClient memberClient;

@Override
Expand All @@ -23,7 +27,17 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuthUserInfo userInfo = OAuthUserInfo.of(registrationId, oAuth2User.getAttributes());
SocialLoginResult result = memberClient.resolveSocialLogin(userInfo);
requireEmailForOnboarding(result, userInfo.email());

return new SocialAuthPrincipal(result, userInfo, oAuth2User.getAttributes());
}

static void requireEmailForOnboarding(SocialLoginResult result, String email) {
if (result.outcome() != SocialLoginResult.Outcome.EXISTING && !StringUtils.hasText(email)) {
throw new OAuth2AuthenticationException(
new OAuth2Error(EMAIL_REQUIRED_ERROR),
"Social account email is required for signup or account linking"
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.nalssilog.auth.config.AuthProperties;
import com.nalssilog.member.domain.MemberStatus;
import com.nalssilog.member.domain.Provider;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
Expand Down Expand Up @@ -35,12 +36,13 @@ public JwtTokenProvider(AuthProperties properties) {
this.accessTokenTtl = properties.jwt().accessTokenTtl();
}

public String createAccessToken(Long memberId, MemberStatus status) {
public String createAccessToken(Long memberId, MemberStatus status, Provider provider) {
Instant now = Instant.now();

return Jwts.builder()
.subject(String.valueOf(memberId))
.claim("status", status.name())
.claim("provider", provider.name())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(accessTokenTtl)))
.signWith(key)
Expand All @@ -54,16 +56,23 @@ public Optional<AccessTokenPayload> parse(String token) {
.build()
.parseSignedClaims(token)
.getPayload();
String status = claims.get("status", String.class);
String provider = claims.get("provider", String.class);

if (status == null || provider == null) {
return Optional.empty();
}

return Optional.of(new AccessTokenPayload(
Long.parseLong(claims.getSubject()),
MemberStatus.valueOf(claims.get("status", String.class))
MemberStatus.valueOf(status),
Provider.valueOf(provider)
));
} catch (JwtException | IllegalArgumentException _) {
return Optional.empty();
}
}

public record AccessTokenPayload(Long memberId, MemberStatus status) {
public record AccessTokenPayload(Long memberId, MemberStatus status, Provider provider) {
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.nalssilog.auth.application.dto;

import com.nalssilog.member.domain.Provider;
import java.time.Instant;

/**
Expand All @@ -10,6 +11,7 @@ public record SessionData(
String tokenHash,
String sessionId,
Long memberId,
Provider provider,
String deviceName,
String ip,
Instant loginAt,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.nalssilog.auth.client;

import com.nalssilog.member.application.MemberAccountService;
import com.nalssilog.member.application.MemberRegistrationService;
import com.nalssilog.member.application.dto.MemberInfo;
import com.nalssilog.member.application.dto.SocialLoginResult;
import com.nalssilog.member.application.dto.TermsAgreement;
Expand All @@ -17,16 +18,16 @@
public class MemberClient {

private final MemberAccountService memberAccountService;
private final MemberRegistrationService memberRegistrationService;

public SocialLoginResult resolveSocialLogin(OAuthUserInfo userInfo) {
return memberAccountService.resolveSocialLogin(
userInfo.provider(), userInfo.providerUserId(), userInfo.email());
}

public MemberInfo registerMember(OAuthUserInfo userInfo, String name, String nickname,
List<TermsAgreement> agreedTerms) {
return memberAccountService.registerMember(
userInfo.provider(), userInfo.providerUserId(), userInfo.email(), name, nickname, agreedTerms);
public MemberInfo registerMember(OAuthUserInfo userInfo, List<TermsAgreement> agreedTerms) {
return memberRegistrationService.registerMember(
userInfo.provider(), userInfo.providerUserId(), userInfo.email(), userInfo.socialName(), agreedTerms);
}

public MemberInfo linkSocial(Long targetMemberId, OAuthUserInfo userInfo) {
Expand Down
11 changes: 8 additions & 3 deletions auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public record OAuthUserInfo(
Provider provider,
String providerUserId,
String email,
String nickname
String socialName
) {

private static final String EMAIL_KEY = "email";
Expand Down Expand Up @@ -39,14 +39,19 @@ yield new OAuthUserInfo(
}
case NAVER -> {
Map<String, Object> response = (Map<String, Object>) attributes.getOrDefault("response", Map.of());
String name = text(response.get("name"));

yield new OAuthUserInfo(
provider,
String.valueOf(response.get("id")),
(String) response.get(EMAIL_KEY),
(String) response.get("nickname")
text(response.get(EMAIL_KEY)),
name == null || name.isBlank() ? text(response.get("nickname")) : name
);
}
};
}

private static String text(Object value) {
return value == null ? null : String.valueOf(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(HttpMethod.POST, "/api/feedbacks").permitAll()
// 익명 제보·감사해요 허용 (익명 UUID 쿠키로 식별)
.requestMatchers(HttpMethod.POST, "/api/reports/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/reports/*").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/reports/*/thanks").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
payload.memberId(),
null,
List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status()))));
authentication.setDetails(payload.provider());
SecurityContextHolder.getContext().setAuthentication(authentication);
// STATELESS 라 체인 종료 후 SecurityContext 가 비므로, 액세스 로그용으로 memberId 를 남겨둔다.
request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId());
Expand Down
Loading