diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 18fb516..6103e4b 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -147,10 +147,6 @@ jobs: DB_NAME: ${{ secrets.DEV_DB_NAME }} DB_USERNAME: ${{ secrets.DEV_DB_USERNAME }} DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }} - OAUTH_GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }} - OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} - OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }} - OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }} APPLE_CLIENT_ID: ${{ secrets.DEV_OAUTH_APPLE_CLIENT_ID }} APPLE_TEAM_ID: ${{ secrets.DEV_OAUTH_APPLE_TEAM_ID }} APPLE_KEY_ID: ${{ secrets.DEV_OAUTH_APPLE_KEY_ID }} @@ -164,7 +160,7 @@ jobs: host: ${{ secrets.EC2_HOST_DEV }} username: ${{ secrets.EC2_USER_DEV }} key: ${{ secrets.EC2_SSH_KEY_DEV }} - envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY + envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-dev-api @@ -197,10 +193,6 @@ jobs: DEV_DB_URL=jdbc:postgresql://$DB_HOST:$DB_PORT/$DB_NAME?sslmode=require DEV_DB_USERNAME=$DB_USERNAME DEV_DB_PASSWORD=$DB_PASSWORD - OAUTH_GOOGLE_CLIENT_ID=${OAUTH_GOOGLE_CLIENT_ID:-dummy} - OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET:-dummy} - OAUTH_KAKAO_CLIENT_ID=${OAUTH_KAKAO_CLIENT_ID:-dummy} - OAUTH_KAKAO_CLIENT_SECRET=${OAUTH_KAKAO_CLIENT_SECRET:-dummy} OAUTH_APPLE_CLIENT_ID=${APPLE_CLIENT_ID:-} OAUTH_APPLE_TEAM_ID=${APPLE_TEAM_ID:-} OAUTH_APPLE_KEY_ID=${APPLE_KEY_ID:-} diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 50d8d30..00c6a03 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -137,10 +137,6 @@ jobs: REDIS_HOST: ${{ secrets.PROD_REDIS_HOST }} REDIS_PORT: ${{ secrets.PROD_REDIS_PORT }} REDIS_PASSWORD: ${{ secrets.PROD_REDIS_PASSWORD }} - OAUTH_GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }} - OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} - OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }} - OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }} APPLE_CLIENT_ID: ${{ secrets.PROD_OAUTH_APPLE_CLIENT_ID }} APPLE_TEAM_ID: ${{ secrets.PROD_OAUTH_APPLE_TEAM_ID }} APPLE_KEY_ID: ${{ secrets.PROD_OAUTH_APPLE_KEY_ID }} @@ -154,7 +150,7 @@ jobs: host: ${{ secrets.EC2_HOST_PROD }} username: ${{ secrets.EC2_USER_PROD }} key: ${{ secrets.EC2_SSH_KEY_PROD }} - envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY + envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-prod-api @@ -190,10 +186,6 @@ jobs: PROD_REDIS_HOST=$REDIS_HOST PROD_REDIS_PORT=$REDIS_PORT PROD_REDIS_PASSWORD=$REDIS_PASSWORD - OAUTH_GOOGLE_CLIENT_ID=${OAUTH_GOOGLE_CLIENT_ID:-dummy} - OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET:-dummy} - OAUTH_KAKAO_CLIENT_ID=${OAUTH_KAKAO_CLIENT_ID:-dummy} - OAUTH_KAKAO_CLIENT_SECRET=${OAUTH_KAKAO_CLIENT_SECRET:-dummy} OAUTH_APPLE_CLIENT_ID=${APPLE_CLIENT_ID:-} OAUTH_APPLE_TEAM_ID=${APPLE_TEAM_ID:-} OAUTH_APPLE_KEY_ID=${APPLE_KEY_ID:-} diff --git a/app/src/main/resources/db/migration/V12__add_report_consents.sql b/app/src/main/resources/db/migration/V12__add_report_consents.sql new file mode 100644 index 0000000..17a2b17 --- /dev/null +++ b/app/src/main/resources/db/migration/V12__add_report_consents.sql @@ -0,0 +1,20 @@ +CREATE TABLE report_consent ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + report_id bigint NOT NULL, + terms_type character varying(20) NOT NULL, + version character varying(20) NOT NULL, + agreed_at timestamp(6) with time zone NOT NULL, + CONSTRAINT fk_report_consent_report + FOREIGN KEY (report_id) + REFERENCES weather_report (id) + ON DELETE CASCADE, + CONSTRAINT uk_report_consent_report_type + UNIQUE (report_id, terms_type), + CONSTRAINT chk_report_consent_terms_type + CHECK (terms_type IN ('SERVICE', 'PRIVACY')) +); + +CREATE INDEX idx_report_consent_report + ON report_consent (report_id); diff --git a/app/src/main/resources/db/migration/V13__add_report_moderation.sql b/app/src/main/resources/db/migration/V13__add_report_moderation.sql new file mode 100644 index 0000000..249e91d --- /dev/null +++ b/app/src/main/resources/db/migration/V13__add_report_moderation.sql @@ -0,0 +1,133 @@ +ALTER TABLE member + ADD COLUMN role character varying(20) NOT NULL DEFAULT 'MEMBER'; + +ALTER TABLE member + ADD CONSTRAINT chk_member_role + CHECK (role IN ('MEMBER', 'MODERATOR', 'ADMIN')); + +CREATE TABLE member_role_change ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + target_member_id bigint NOT NULL, + changed_by_member_id bigint NOT NULL, + previous_role character varying(20) NOT NULL, + new_role character varying(20) NOT NULL, + CONSTRAINT chk_member_role_change_previous + CHECK (previous_role IN ('MEMBER', 'MODERATOR', 'ADMIN')), + CONSTRAINT chk_member_role_change_new + CHECK (new_role IN ('MEMBER', 'MODERATOR', 'ADMIN')) +); + +CREATE INDEX idx_member_role_change_target + ON member_role_change (target_member_id, created_at); +CREATE INDEX idx_member_role_change_admin + ON member_role_change (changed_by_member_id, created_at); + +ALTER TABLE weather_report + ADD COLUMN moderation_status character varying(20) NOT NULL DEFAULT 'VISIBLE'; + +ALTER TABLE weather_report + ADD CONSTRAINT chk_weather_report_moderation_status + CHECK (moderation_status IN ('VISIBLE', 'HIDDEN', 'REMOVED')); + +CREATE INDEX idx_weather_report_visible_location_created + ON weather_report (location_id, created_at DESC, id DESC) + WHERE moderation_status = 'VISIBLE'; + +CREATE TABLE report_flag ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + report_id bigint NOT NULL, + reporter_type character varying(20) NOT NULL, + reporter_key character varying(64) NOT NULL, + reason character varying(30) NOT NULL, + detail character varying(500), + status character varying(20) NOT NULL, + processed_at timestamp(6) with time zone, + processed_by_member_id bigint, + resolution_note character varying(500), + CONSTRAINT fk_report_flag_report + FOREIGN KEY (report_id) REFERENCES weather_report (id) ON DELETE CASCADE, + CONSTRAINT uk_report_flag_report_reporter + UNIQUE (report_id, reporter_type, reporter_key), + CONSTRAINT chk_report_flag_reporter_type + CHECK (reporter_type IN ('MEMBER', 'ANONYMOUS')), + CONSTRAINT chk_report_flag_reason + CHECK (reason IN ('SPAM', 'ABUSE', 'HATE', 'SEXUAL', 'PRIVACY', 'FALSE_INFORMATION', 'OTHER')), + CONSTRAINT chk_report_flag_status + CHECK (status IN ('PENDING', 'RESOLVED', 'REJECTED')) +); + +CREATE INDEX idx_report_flag_status_created + ON report_flag (status, created_at); +CREATE INDEX idx_report_flag_report + ON report_flag (report_id); + +CREATE TABLE actor_block ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + blocker_type character varying(20) NOT NULL, + blocker_key character varying(64) NOT NULL, + blocked_type character varying(20) NOT NULL, + blocked_key character varying(64) NOT NULL, + CONSTRAINT uk_actor_block_pair + UNIQUE (blocker_type, blocker_key, blocked_type, blocked_key), + CONSTRAINT chk_actor_block_blocker_type + CHECK (blocker_type = 'MEMBER'), + CONSTRAINT chk_actor_block_blocked_type + CHECK (blocked_type = 'MEMBER') +); + +CREATE INDEX idx_actor_block_blocker + ON actor_block (blocker_type, blocker_key); + +CREATE TABLE actor_restriction ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + actor_type character varying(20) NOT NULL, + actor_key character varying(64) NOT NULL, + source_report_id bigint, + reason character varying(500) NOT NULL, + expires_at timestamp(6) with time zone, + created_by_member_id bigint NOT NULL, + lifted_at timestamp(6) with time zone, + lifted_by_member_id bigint, + CONSTRAINT fk_actor_restriction_source_report + FOREIGN KEY (source_report_id) REFERENCES weather_report (id) ON DELETE SET NULL, + CONSTRAINT chk_actor_restriction_actor_type + CHECK (actor_type IN ('MEMBER', 'ANONYMOUS')) +); + +CREATE INDEX idx_actor_restriction_actor + ON actor_restriction (actor_type, actor_key, lifted_at, expires_at); + +CREATE TABLE moderation_action ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + action_type character varying(40) NOT NULL, + admin_member_id bigint NOT NULL, + report_id bigint, + target_actor_type character varying(20), + target_actor_key character varying(64), + reason character varying(500) NOT NULL, + CONSTRAINT fk_moderation_action_report + FOREIGN KEY (report_id) REFERENCES weather_report (id) ON DELETE SET NULL, + CONSTRAINT chk_moderation_action_type + CHECK (action_type IN ( + 'REPORT_HIDDEN', 'REPORT_RESTORED', 'REPORT_REMOVED', + 'FLAG_RESOLVED', 'FLAG_REJECTED', + 'AUTHOR_RESTRICTED', 'AUTHOR_RESTRICTION_LIFTED' + )), + CONSTRAINT chk_moderation_action_target_type + CHECK (target_actor_type IS NULL OR target_actor_type IN ('MEMBER', 'ANONYMOUS')) +); + +CREATE INDEX idx_moderation_action_report + ON moderation_action (report_id, created_at); +CREATE INDEX idx_moderation_action_admin + ON moderation_action (admin_member_id, created_at); diff --git a/app/src/main/resources/db/migration/V14__index_actor_block_blocked.sql b/app/src/main/resources/db/migration/V14__index_actor_block_blocked.sql new file mode 100644 index 0000000..0a09175 --- /dev/null +++ b/app/src/main/resources/db/migration/V14__index_actor_block_blocked.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_actor_block_blocked + ON actor_block (blocked_type, blocked_key); diff --git a/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java index d884f21..b83f531 100644 --- a/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java +++ b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java @@ -7,6 +7,7 @@ import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.application.dto.TermsAgreement; import com.nalssilog.member.domain.Provider; +import com.nalssilog.member.domain.MemberRole; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -49,6 +50,10 @@ public Optional findMemberInfo(Long memberId) { return memberAccountService.findMemberInfo(memberId); } + public Optional findRole(Long memberId) { + return memberAccountService.findRole(memberId); + } + public Optional findMemberInfo( Provider provider, String providerUserId diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java index e050021..b81bfc2 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java @@ -76,6 +76,7 @@ private static User user(MemberInfo member) { return new User( String.valueOf(member.id()), member.nickname(), - new Avatar(member.avatarType(), member.avatarValue())); + new Avatar(member.avatarType(), member.avatarValue()), + member.role()); } } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java index 2a8fce2..eb0aba2 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java @@ -1,22 +1,60 @@ package com.nalssilog.auth.mobile.oauth; +import com.nalssilog.auth.ticket.AuthChannel; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.stereotype.Component; +import org.springframework.util.Assert; @Component +@RequiredArgsConstructor public class MobileOAuthAuthorizationRequestRepository implements AuthorizationRequestRepository { - private final HttpSessionOAuth2AuthorizationRequestRepository delegate = + private static final String AUTHORIZATION_REQUESTS_ATTRIBUTE = + MobileOAuthAuthorizationRequestRepository.class.getName() + + ".AUTHORIZATION_REQUESTS"; + private static final int MAX_AUTHORIZATION_REQUESTS = 16; + + private final HttpSessionOAuth2AuthorizationRequestRepository legacyDelegate = new HttpSessionOAuth2AuthorizationRequestRepository(); + private final MobileOAuthAuthorizationRequestStore mobileStore; @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { - return delegate.loadAuthorizationRequest(request); + Assert.notNull(request, "request cannot be null"); + + String state = request.getParameter(OAuth2ParameterNames.STATE); + Optional mobileTransaction = + MobileOAuthRequestAttributes.transactionIdFromState(state); + + if (mobileTransaction.isPresent()) { + Optional mobileRequest = + mobileStore.find(mobileTransaction.get()); + + if (mobileRequest.isPresent()) { + return mobileRequest.get(); + } + } + + HttpSession session = request.getSession(false); + + if (state == null || session == null) { + return null; + } + + synchronized (session) { + return authorizationRequests(session).get(state); + } } @Override @@ -25,7 +63,44 @@ public void saveAuthorizationRequest( HttpServletRequest request, HttpServletResponse response ) { - delegate.saveAuthorizationRequest(authorizationRequest, request, response); + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); + + if (authorizationRequest == null) { + removeAuthorizationRequest(request, response); + + return; + } + + String state = authorizationRequest.getState(); + + Assert.hasText(state, "authorizationRequest.state cannot be empty"); + + Optional mobileTransaction = + authorizationTransaction(authorizationRequest) + .or(() -> MobileOAuthRequestAttributes.transactionIdFromState(state)); + + if (mobileTransaction.isPresent()) { + mobileStore.save(mobileTransaction.get(), authorizationRequest); + + return; + } + + HttpSession session = request.getSession(); + + synchronized (session) { + LinkedHashMap updated = + new LinkedHashMap<>(authorizationRequests(session)); + + updated.remove(state); + updated.put(state, authorizationRequest); + + while (updated.size() > MAX_AUTHORIZATION_REQUESTS) { + updated.remove(updated.keySet().iterator().next()); + } + + session.setAttribute(AUTHORIZATION_REQUESTS_ATTRIBUTE, updated); + } } @Override @@ -33,18 +108,138 @@ public OAuth2AuthorizationRequest removeAuthorizationRequest( HttpServletRequest request, HttpServletResponse response ) { - OAuth2AuthorizationRequest authorizationRequest = - delegate.removeAuthorizationRequest(request, response); + Assert.notNull(request, "request cannot be null"); + Assert.notNull(response, "response cannot be null"); - if (authorizationRequest != null) { - String transactionId = authorizationRequest.getAttribute( - MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE); + String state = request.getParameter(OAuth2ParameterNames.STATE); + Optional mobileTransaction = + MobileOAuthRequestAttributes.transactionIdFromState(state); + OAuth2AuthorizationRequest mobileRequest = mobileTransaction + .flatMap(mobileStore::take) + .orElse(null); + OAuth2AuthorizationRequest sessionRequest = removeByState(request, state); + OAuth2AuthorizationRequest authorizationRequest = mobileRequest != null + ? mobileRequest + : sessionRequest; - if (transactionId != null && !transactionId.isBlank()) { - MobileOAuthRequestAttributes.expose(request, transactionId); - } + if (authorizationRequest == null) { + authorizationRequest = legacyDelegate.removeAuthorizationRequest(request, response); } + exposeFlow(request, state, authorizationRequest); + return authorizationRequest; } + + private OAuth2AuthorizationRequest removeByState( + HttpServletRequest request, + String state + ) { + HttpSession session = request.getSession(false); + + if (state == null || session == null) { + return null; + } + + synchronized (session) { + LinkedHashMap updated = + new LinkedHashMap<>(authorizationRequests(session)); + OAuth2AuthorizationRequest authorizationRequest = updated.remove(state); + + if (authorizationRequest == null) { + return null; + } + + if (updated.isEmpty()) { + session.removeAttribute(AUTHORIZATION_REQUESTS_ATTRIBUTE); + } else { + session.setAttribute(AUTHORIZATION_REQUESTS_ATTRIBUTE, updated); + } + + return authorizationRequest; + } + } + + private void exposeFlow( + HttpServletRequest request, + String state, + OAuth2AuthorizationRequest authorizationRequest + ) { + Optional channel = authorizationChannel(authorizationRequest) + .or(() -> MobileOAuthRequestAttributes.channelFromState(state)); + String transactionId = authorizationTransaction(authorizationRequest) + .or(() -> MobileOAuthRequestAttributes.transactionIdFromState(state)) + .orElse(null); + + if (channel.isEmpty() && transactionId != null) { + channel = Optional.of(AuthChannel.MOBILE); + } + + channel.ifPresent(authChannel -> MobileOAuthRequestAttributes.expose( + request, + authChannel, + transactionId)); + } + + private Optional authorizationChannel( + OAuth2AuthorizationRequest authorizationRequest + ) { + if (authorizationRequest == null) { + return Optional.empty(); + } + + Object value = authorizationRequest.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE); + + if (value instanceof AuthChannel channel) { + return Optional.of(channel); + } + + if (value instanceof String text) { + try { + return Optional.of(AuthChannel.valueOf(text)); + } catch (IllegalArgumentException _) { + return Optional.empty(); + } + } + + return Optional.empty(); + } + + private Optional authorizationTransaction( + OAuth2AuthorizationRequest authorizationRequest + ) { + if (authorizationRequest == null) { + return Optional.empty(); + } + + String transactionId = authorizationRequest.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE); + + return transactionId == null || transactionId.isBlank() + ? Optional.empty() + : Optional.of(transactionId); + } + + private Map authorizationRequests( + HttpSession session + ) { + Object value = session.getAttribute(AUTHORIZATION_REQUESTS_ATTRIBUTE); + + if (!(value instanceof Map stored)) { + return Map.of(); + } + + LinkedHashMap requests = + new LinkedHashMap<>(); + + stored.forEach((state, authorizationRequest) -> { + if (state instanceof String text + && authorizationRequest instanceof OAuth2AuthorizationRequest request) { + requests.put(text, request); + } + }); + + return requests; + } } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java index 22ee758..13b9904 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java @@ -1,6 +1,7 @@ package com.nalssilog.auth.mobile.oauth; import com.nalssilog.auth.oauth.apple.AppleOAuthProperties; +import com.nalssilog.auth.ticket.AuthChannel; import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; @@ -75,7 +76,12 @@ private OAuth2AuthorizationRequest attachMobileTransaction( MobileOAuthRequestAttributes.TRANSACTION_PARAMETER); if (values == null || values.length == 0) { - return authorizationRequest; + return withFlow( + authorizationRequest, + AuthChannel.WEB, + null, + MobileOAuthRequestAttributes.webState( + authorizationRequest.getState())); } if (values.length != 1 || values[0] == null || values[0].isBlank()) { @@ -93,10 +99,32 @@ private OAuth2AuthorizationRequest attachMobileTransaction( throw invalidTransaction(); } + return withFlow( + authorizationRequest, + AuthChannel.MOBILE, + transactionId, + MobileOAuthRequestAttributes.mobileState(transactionId)); + } + + private OAuth2AuthorizationRequest withFlow( + OAuth2AuthorizationRequest authorizationRequest, + AuthChannel channel, + String transactionId, + String state + ) { return OAuth2AuthorizationRequest.from(authorizationRequest) - .attributes(attributes -> attributes.put( - MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, - transactionId)) + .state(state) + .attributes(attributes -> { + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE, + channel); + + if (transactionId != null) { + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, + transactionId); + } + }) .build(); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestStore.java new file mode 100644 index 0000000..842550a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestStore.java @@ -0,0 +1,152 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.security.SecretFingerprint; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Repository +@RequiredArgsConstructor +public class MobileOAuthAuthorizationRequestStore { + + private static final String KEY_PREFIX = "auth:mobile:authorization-request:"; + private static final Pattern TRANSACTION_ID_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{43}$"); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final AuthProperties properties; + + public void save( + String transactionId, + OAuth2AuthorizationRequest authorizationRequest + ) { + redisTemplate.opsForValue().set( + key(transactionId), + serialize(AuthorizationRequestSnapshot.from(authorizationRequest)), + properties.mobile().transactionTtl()); + } + + public Optional find(String transactionId) { + if (!validTransactionId(transactionId)) { + return Optional.empty(); + } + + return deserialize(redisTemplate.opsForValue().get(key(transactionId)), transactionId); + } + + public Optional take(String transactionId) { + if (!validTransactionId(transactionId)) { + return Optional.empty(); + } + + return deserialize( + redisTemplate.opsForValue().getAndDelete(key(transactionId)), + transactionId); + } + + private String key(String transactionId) { + return KEY_PREFIX + SecretFingerprint.sha256(transactionId); + } + + private boolean validTransactionId(String transactionId) { + return transactionId != null + && TRANSACTION_ID_PATTERN.matcher(transactionId).matches(); + } + + private String serialize(AuthorizationRequestSnapshot snapshot) { + try { + return objectMapper.writeValueAsString(snapshot); + } catch (JacksonException exception) { + throw new IllegalStateException( + "mobile OAuth authorization request serialization failed", + exception); + } + } + + private Optional deserialize( + String value, + String transactionId + ) { + if (value == null) { + return Optional.empty(); + } + + try { + OAuth2AuthorizationRequest authorizationRequest = objectMapper + .readValue(value, AuthorizationRequestSnapshot.class) + .toAuthorizationRequest(); + + if (!MobileOAuthRequestAttributes.mobileState(transactionId) + .equals(authorizationRequest.getState())) { + return Optional.empty(); + } + + return Optional.of(authorizationRequest); + } catch (JacksonException exception) { + throw new IllegalStateException( + "mobile OAuth authorization request deserialization failed", + exception); + } + } + + private record AuthorizationRequestSnapshot( + String authorizationUri, + String clientId, + String redirectUri, + Set scopes, + String state, + Map additionalParameters, + Map attributes, + String authorizationRequestUri + ) { + + private static AuthorizationRequestSnapshot from( + OAuth2AuthorizationRequest authorizationRequest + ) { + return new AuthorizationRequestSnapshot( + authorizationRequest.getAuthorizationUri(), + authorizationRequest.getClientId(), + authorizationRequest.getRedirectUri(), + Set.copyOf(authorizationRequest.getScopes()), + authorizationRequest.getState(), + stringMap(authorizationRequest.getAdditionalParameters()), + stringMap(authorizationRequest.getAttributes()), + authorizationRequest.getAuthorizationRequestUri()); + } + + private OAuth2AuthorizationRequest toAuthorizationRequest() { + return OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(authorizationUri) + .clientId(clientId) + .redirectUri(redirectUri) + .scopes(scopes) + .state(state) + .additionalParameters(new LinkedHashMap<>(additionalParameters)) + .attributes(new LinkedHashMap<>(attributes)) + .authorizationRequestUri(authorizationRequestUri) + .build(); + } + + private static Map stringMap(Map values) { + LinkedHashMap result = new LinkedHashMap<>(); + + values.forEach((key, value) -> { + if (value != null) { + result.put(key, String.valueOf(value)); + } + }); + + return result; + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java index 62ce9a2..8a82456 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java @@ -1,28 +1,113 @@ package com.nalssilog.auth.mobile.oauth; +import com.nalssilog.auth.ticket.AuthChannel; import jakarta.servlet.http.HttpServletRequest; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; public final class MobileOAuthRequestAttributes { public static final String TRANSACTION_PARAMETER = "mobile_transaction"; public static final String AUTHORIZATION_ATTRIBUTE = MobileOAuthRequestAttributes.class.getName() + ".authorizationTransaction"; - private static final String REQUEST_ATTRIBUTE = + public static final String AUTHORIZATION_CHANNEL_ATTRIBUTE = + MobileOAuthRequestAttributes.class.getName() + ".authorizationChannel"; + private static final String REQUEST_TRANSACTION_ATTRIBUTE = MobileOAuthRequestAttributes.class.getName() + ".requestTransaction"; + private static final String REQUEST_CHANNEL_ATTRIBUTE = + MobileOAuthRequestAttributes.class.getName() + ".requestChannel"; + private static final String MOBILE_STATE_PREFIX = "m."; + private static final String WEB_STATE_PREFIX = "w."; + private static final Pattern MOBILE_STATE_PATTERN = + Pattern.compile("^m\\.([A-Za-z0-9_-]{43})$"); + private static final Pattern TRANSACTION_ID_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{43}$"); private MobileOAuthRequestAttributes() { } - public static void expose(HttpServletRequest request, String transactionId) { - request.setAttribute(REQUEST_ATTRIBUTE, transactionId); + public static String mobileState(String transactionId) { + if (transactionId == null + || !TRANSACTION_ID_PATTERN.matcher(transactionId).matches()) { + throw new IllegalArgumentException("Invalid mobile OAuth transaction id"); + } + + return MOBILE_STATE_PREFIX + transactionId; + } + + public static String webState(String generatedState) { + if (generatedState == null || generatedState.isBlank()) { + throw new IllegalArgumentException("OAuth state must not be blank"); + } + + return WEB_STATE_PREFIX + generatedState; + } + + public static void expose( + HttpServletRequest request, + AuthChannel channel, + String transactionId + ) { + request.setAttribute(REQUEST_CHANNEL_ATTRIBUTE, channel); + + if (transactionId != null && !transactionId.isBlank()) { + request.setAttribute(REQUEST_TRANSACTION_ATTRIBUTE, transactionId); + } + } + + public static Optional channel(HttpServletRequest request) { + Object value = request.getAttribute(REQUEST_CHANNEL_ATTRIBUTE); + + if (value instanceof AuthChannel channel) { + return Optional.of(channel); + } + + String state = request.getParameter(OAuth2ParameterNames.STATE); + + if (state != null && state.startsWith(MOBILE_STATE_PREFIX)) { + return Optional.of(AuthChannel.MOBILE); + } + + if (state != null && state.startsWith(WEB_STATE_PREFIX)) { + return Optional.of(AuthChannel.WEB); + } + + return Optional.empty(); } public static Optional transactionId(HttpServletRequest request) { - Object value = request.getAttribute(REQUEST_ATTRIBUTE); + Object value = request.getAttribute(REQUEST_TRANSACTION_ATTRIBUTE); + + if (value instanceof String text && !text.isBlank()) { + return Optional.of(text); + } + + return transactionIdFromState(request.getParameter(OAuth2ParameterNames.STATE)); + } + + public static Optional channelFromState(String state) { + if (state != null && state.startsWith(MOBILE_STATE_PREFIX)) { + return Optional.of(AuthChannel.MOBILE); + } + + if (state != null && state.startsWith(WEB_STATE_PREFIX)) { + return Optional.of(AuthChannel.WEB); + } + + return Optional.empty(); + } + + public static Optional transactionIdFromState(String state) { + if (state == null) { + return Optional.empty(); + } + + Matcher matcher = MOBILE_STATE_PATTERN.matcher(state); - return value instanceof String text && !text.isBlank() - ? Optional.of(text) + return matcher.matches() + ? Optional.of(matcher.group(1)) : Optional.empty(); } } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java index 3b1cc11..0fbea10 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java @@ -151,6 +151,15 @@ public String complete(String transactionId, SocialPrincipal principal) { AuthErrorCode.AUTH_MOBILE_TRANSACTION_EXPIRED)); MobileOAuthGrant grant = resolveGrant(transaction, principal); + if (grant.result() == MobileAuthResult.FAILED) { + discardPendingLink(transaction); + + return issueFailureCallback( + transaction, + grant.errorCode(), + "OAuth authentication failed"); + } + return issueCallback(transaction, grant); } @@ -171,11 +180,20 @@ private MobileOAuthGrant resolveGrant( }; } - public Optional completeFailure(String transactionId, String errorCode) { + public Optional completeFailure( + String transactionId, + String error, + String errorDescription + ) { return transactionStore.take(transactionId) - .map(transaction -> issueCallback( - transaction, - MobileOAuthGrant.failed(transaction.provider(), errorCode))); + .map(transaction -> { + discardPendingLink(transaction); + + return issueFailureCallback( + transaction, + error, + errorDescription); + }); } public ExchangeResult exchange( @@ -198,7 +216,7 @@ public ExchangeResult exchange( return switch (grant.result()) { case SUCCESS -> authenticated(grant, MobileAuthResult.SUCCESS, device); case LINK_SUCCESS -> grant.issueTokens() - ? authenticated(grant, MobileAuthResult.LINK_SUCCESS, device) + ? authenticated(grant, MobileAuthResult.SUCCESS, device) : linkedWithoutTokens(grant); case SIGNUP_REQUIRED -> signupRequired(grant); case LINK_REQUIRED -> linkRequired(grant); @@ -298,10 +316,9 @@ private MobileOAuthGrant completeLoginLink( ticket.email(), null)); - return MobileOAuthGrant.linkSuccess( + return MobileOAuthGrant.success( member.id(), - ticket.provider(), - true); + ticket.provider()); } catch (NalssiLogException _) { return MobileOAuthGrant.failed( transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); @@ -440,6 +457,30 @@ private String issueCallback( .toUriString(); } + private String issueFailureCallback( + MobileOAuthTransaction transaction, + String error, + String errorDescription + ) { + return UriComponentsBuilder + .fromUriString(transaction.redirectUri()) + .queryParam("error", error) + .queryParam("error_description", errorDescription) + .queryParam("state", transaction.appState()) + .build() + .encode() + .toUriString(); + } + + private void discardPendingLink(MobileOAuthTransaction transaction) { + if (transaction.referenceId() == null) { + return; + } + + ticketStore.deleteLink(transaction.referenceId()); + ticketStore.deleteLinkConsent(transaction.referenceId()); + } + private void validateStart( Provider provider, String redirectUri, diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java index 8084d44..a55b1b5 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java @@ -4,6 +4,8 @@ import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.web.AuthCookieManager; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -21,6 +23,8 @@ public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { private final MobileOAuthService mobileOAuthService; + private final WebOAuthService webOAuthService; + private final AuthCookieManager cookieManager; @Value("${nalssilog.frontend.base-url}") private String frontendBaseUrl; @@ -36,21 +40,100 @@ public void onAuthenticationFailure( log.warn("OAuth2 login failed code={} type={}", code, exception.getClass().getSimpleName()); + if (MobileOAuthRequestAttributes.channel(request) + .filter(channel -> channel == AuthChannel.MOBILE) + .isPresent()) { + redirectMobileFailure( + request, + response, + resolveMobileFailure(exception)); + + return; + } + + if ("OAUTH_CANCELLED".equals(code)) { + cancelWebPendingAuthentication(request, response); + } + + response.sendRedirect(frontendBaseUrl + "/auth/callback?result=FAILED&code=" + code); + } + + private void cancelWebPendingAuthentication( + HttpServletRequest request, + HttpServletResponse response + ) { + webOAuthService.cancelPendingAuthentication( + cookieManager.readSignupTicket(request), + cookieManager.readLinkTicket(request), + cookieManager.readLinkIntent(request)); + + cookieManager.clearSignupTicketCookie(response); + cookieManager.clearLinkTicketCookie(response); + cookieManager.clearLinkIntentCookie(response); + } + + private void redirectMobileFailure( + HttpServletRequest request, + HttpServletResponse response, + MobileFailure failure + ) throws IOException { var mobileTransaction = MobileOAuthRequestAttributes.transactionId(request); - if (mobileTransaction.isPresent()) { - var callback = mobileOAuthService.completeFailure( - mobileTransaction.get(), - code); + if (mobileTransaction.isEmpty()) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + + return; + } + + var callback = mobileOAuthService.completeFailure( + mobileTransaction.get(), + failure.error(), + failure.description()); - if (callback.isPresent()) { - response.sendRedirect(callback.get()); + if (callback.isPresent()) { + response.sendRedirect(callback.get()); - return; - } + return; } - response.sendRedirect(frontendBaseUrl + "/auth/callback?result=FAILED&code=" + code); + response.setStatus(HttpServletResponse.SC_GONE); + } + + private MobileFailure resolveMobileFailure(AuthenticationException exception) { + if (exception instanceof OAuth2AuthenticationException oauthException) { + String error = oauthException.getError().getErrorCode(); + + return new MobileFailure( + hasText(error) ? error : "oauth_failed", + safeDescription( + oauthException.getError().getDescription(), + defaultDescription(error))); + } + + return new MobileFailure( + "oauth_failed", + "OAuth authentication failed"); + } + + private String defaultDescription(String error) { + return switch (error == null ? "" : error) { + case "access_denied" -> "OAuth authorization was cancelled"; + case EMAIL_REQUIRED_ERROR -> "Social account email is required"; + default -> "OAuth authentication failed"; + }; + } + + private String safeDescription(String description, String fallback) { + String value = hasText(description) ? description : fallback; + String sanitized = value.replace('\r', ' ').replace('\n', ' ').trim(); + + return sanitized.length() <= 500 + ? sanitized + : sanitized.substring(0, 500); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); } private String resolveCode(AuthenticationException exception) { @@ -64,4 +147,7 @@ private String resolveCode(AuthenticationException exception) { return "OAUTH_FAILED"; } + + private record MobileFailure(String error, String description) { + } } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java index 639c773..e0d22fb 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java @@ -4,6 +4,7 @@ import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; import com.nalssilog.auth.mobile.oauth.MobileOAuthService; import com.nalssilog.auth.oauth.WebOAuthService.Completion; +import com.nalssilog.auth.ticket.AuthChannel; import com.nalssilog.auth.web.AuthCookieManager; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -39,10 +40,18 @@ public void onAuthenticationSuccess( (SocialPrincipal) authentication.getPrincipal(), "principal must not be null"); - Optional mobileTransaction = - MobileOAuthRequestAttributes.transactionId(request); + if (MobileOAuthRequestAttributes.channel(request) + .filter(channel -> channel == AuthChannel.MOBILE) + .isPresent()) { + Optional mobileTransaction = + MobileOAuthRequestAttributes.transactionId(request); + + if (mobileTransaction.isEmpty()) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + + return; + } - if (mobileTransaction.isPresent()) { response.sendRedirect(mobileOAuthService.complete( mobileTransaction.get(), principal)); diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java index 47a7f38..fe2ae7b 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java @@ -39,6 +39,19 @@ public class WebOAuthService { private final MemberClient memberClient; private final AuthProperties properties; + public void cancelPendingAuthentication( + Optional signupTicketId, + Optional linkTicketId, + Optional linkIntentId + ) { + signupTicketId.ifPresent(ticketStore::deleteSignup); + linkTicketId.ifPresent(ticketId -> { + ticketStore.deleteLink(ticketId); + ticketStore.deleteLinkConsent(ticketId); + }); + linkIntentId.ifPresent(ticketStore::deleteLinkIntent); + } + public Completion complete( SocialPrincipal principal, Optional linkIntentId, diff --git a/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java index 87e5522..13faac8 100644 --- a/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java +++ b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java @@ -91,6 +91,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/auth/mobile/link/social/**").authenticated() .requestMatchers("/api/auth/link/social/**").authenticated() .requestMatchers("/api/auth/refresh", "/api/auth/logout", "/api/auth/link/**").permitAll() + .requestMatchers(HttpMethod.PATCH, "/api/admin/members/*/role").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/admin/members/role-changes").hasRole("ADMIN") + .requestMatchers("/api/admin/**").hasAnyRole("MODERATOR", "ADMIN") + .requestMatchers("/api/report-blocks", "/api/report-blocks/**").authenticated() .requestMatchers("/api/locations/favorites", "/api/locations/favorites/**").authenticated() .requestMatchers(HttpMethod.GET, "/api/reports/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/locations/**", "/api/reports/**").permitAll() diff --git a/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java index d9127d0..1682ba2 100644 --- a/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java @@ -2,6 +2,7 @@ import com.nalssilog.auth.core.AuthErrorCode; import com.nalssilog.auth.mobile.guest.MobileGuestCredentialService; +import com.nalssilog.auth.member.MemberClient; import com.nalssilog.auth.oauth.SocialAuthPrincipal; import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; import com.nalssilog.auth.security.CredentialAuthenticationException; @@ -12,12 +13,14 @@ import com.nalssilog.common.filter.RequestLoggingFilter; import com.nalssilog.common.security.VerifiedRequestCredentials; import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.MemberRole; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Collections; +import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -41,6 +44,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenStore refreshTokenStore; private final ApiAuthenticationEntryPoint authenticationEntryPoint; + private final MemberClient memberClient; @Override protected void doFilterInternal( @@ -137,10 +141,19 @@ private void setAuthentication( AccessTokenPayload payload, CredentialTransport transport ) { + var authorities = new ArrayList(); + + authorities.add(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status()))); + + if (payload.status() == MemberStatus.ACTIVE && request.getRequestURI().startsWith("/api/admin/")) { + memberClient.findRole(payload.memberId()) + .ifPresent(role -> addOperationalAuthorities(authorities, role)); + } + var authentication = UsernamePasswordAuthenticationToken.authenticated( payload.memberId(), null, - List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); + authorities); authentication.setDetails(new AuthRequestDetails( payload.provider(), payload.sessionId(), transport)); @@ -148,6 +161,19 @@ private void setAuthentication( request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); } + private void addOperationalAuthorities( + List authorities, + MemberRole role + ) { + if (role == MemberRole.MODERATOR || role == MemberRole.ADMIN) { + authorities.add(new SimpleGrantedAuthority("ROLE_MODERATOR")); + } + + if (role == MemberRole.ADMIN) { + authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + } + private void reject( HttpServletRequest request, HttpServletResponse response, diff --git a/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java index 03dde93..e9d0d52 100644 --- a/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java @@ -4,6 +4,7 @@ import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; import com.nalssilog.member.domain.Provider; +import com.nalssilog.member.domain.MemberRole; import java.util.List; /** @@ -17,7 +18,7 @@ public record MeResponse( PendingAuth pendingAuth ) { - public record User(String id, String nickname, Avatar avatar) { + public record User(String id, String nickname, Avatar avatar, MemberRole role) { } public record Avatar(AvatarType type, String value) { @@ -32,7 +33,7 @@ public record PendingAuth(Provider provider, String email, List existi public static MeResponse authenticated(MemberInfo member) { User user = new User(String.valueOf(member.id()), member.nickname(), - new Avatar(member.avatarType(), member.avatarValue())); + new Avatar(member.avatarType(), member.avatarValue()), member.role()); return new MeResponse(true, AuthResult.SUCCESS, user, null); } diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepositoryTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepositoryTest.java new file mode 100644 index 0000000..28f437d --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepositoryTest.java @@ -0,0 +1,176 @@ +package com.nalssilog.auth.mobile.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.ticket.AuthChannel; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +@SuppressWarnings("java:S5960") +class MobileOAuthAuthorizationRequestRepositoryTest { + + private static final String TRANSACTION_ID = "T".repeat(43); + private static final String MOBILE_STATE = + MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID); + private static final String WEB_STATE = + MobileOAuthRequestAttributes.webState("web-state-0123456789"); + + private final MobileOAuthAuthorizationRequestStore mobileStore = + mock(MobileOAuthAuthorizationRequestStore.class); + private final MobileOAuthAuthorizationRequestRepository repository = + new MobileOAuthAuthorizationRequestRepository(mobileStore); + + @Test + void webAndMobileRequestsInSameBrowserAreStoredByState() { + MockHttpSession session = new MockHttpSession(); + OAuth2AuthorizationRequest web = authorizationRequest( + WEB_STATE, + AuthChannel.WEB, + null); + OAuth2AuthorizationRequest mobile = authorizationRequest( + MOBILE_STATE, + AuthChannel.MOBILE, + TRANSACTION_ID); + + save(mobile, session); + save(web, session); + when(mobileStore.take(TRANSACTION_ID)).thenReturn(Optional.of(mobile)); + + MockHttpServletRequest mobileCallback = callback(session, MOBILE_STATE); + OAuth2AuthorizationRequest removedMobile = + repository.removeAuthorizationRequest( + mobileCallback, + new MockHttpServletResponse()); + + assertThat(removedMobile).isEqualTo(mobile); + assertThat(MobileOAuthRequestAttributes.channel(mobileCallback)) + .contains(AuthChannel.MOBILE); + assertThat(MobileOAuthRequestAttributes.transactionId(mobileCallback)) + .contains(TRANSACTION_ID); + verify(mobileStore).save(TRANSACTION_ID, mobile); + + MockHttpServletRequest webCallback = callback(session, WEB_STATE); + + assertThat(repository.loadAuthorizationRequest(webCallback)) + .isEqualTo(web); + assertThat(repository.removeAuthorizationRequest( + webCallback, + new MockHttpServletResponse())) + .isEqualTo(web); + assertThat(MobileOAuthRequestAttributes.channel(webCallback)) + .contains(AuthChannel.WEB); + } + + @Test + void mobileRequestIsRecoveredAndConsumedWithoutSessionCookie() { + OAuth2AuthorizationRequest mobile = authorizationRequest( + MOBILE_STATE, + AuthChannel.MOBILE, + TRANSACTION_ID); + MockHttpServletRequest authorizationRequest = new MockHttpServletRequest(); + + repository.saveAuthorizationRequest( + mobile, + authorizationRequest, + new MockHttpServletResponse()); + + assertThat(authorizationRequest.getSession(false)).isNull(); + verify(mobileStore).save(TRANSACTION_ID, mobile); + + MockHttpServletRequest callback = new MockHttpServletRequest(); + + callback.addParameter("state", MOBILE_STATE); + when(mobileStore.find(TRANSACTION_ID)).thenReturn(Optional.of(mobile)); + when(mobileStore.take(TRANSACTION_ID)) + .thenReturn(Optional.of(mobile)) + .thenReturn(Optional.empty()); + + assertThat(repository.loadAuthorizationRequest(callback)).isEqualTo(mobile); + assertThat(repository.removeAuthorizationRequest( + callback, + new MockHttpServletResponse())).isEqualTo(mobile); + assertThat(MobileOAuthRequestAttributes.channel(callback)) + .contains(AuthChannel.MOBILE); + assertThat(MobileOAuthRequestAttributes.transactionId(callback)) + .contains(TRANSACTION_ID); + + assertThat(repository.removeAuthorizationRequest( + callback, + new MockHttpServletResponse())).isNull(); + } + + @Test + void missingMobileRequestStillIdentifiesFlowWithoutSessionCookie() { + MockHttpServletRequest callback = new MockHttpServletRequest(); + + callback.addParameter("state", MOBILE_STATE); + when(mobileStore.take(TRANSACTION_ID)).thenReturn(Optional.empty()); + + assertThat(repository.removeAuthorizationRequest( + callback, + new MockHttpServletResponse())) + .isNull(); + assertThat(MobileOAuthRequestAttributes.channel(callback)) + .contains(AuthChannel.MOBILE); + assertThat(MobileOAuthRequestAttributes.transactionId(callback)) + .contains(TRANSACTION_ID); + } + + private void save( + OAuth2AuthorizationRequest authorizationRequest, + MockHttpSession session + ) { + MockHttpServletRequest request = new MockHttpServletRequest(); + + request.setSession(session); + repository.saveAuthorizationRequest( + authorizationRequest, + request, + new MockHttpServletResponse()); + } + + private MockHttpServletRequest callback( + MockHttpSession session, + String state + ) { + MockHttpServletRequest request = new MockHttpServletRequest(); + + request.setSession(session); + request.addParameter("state", state); + + return request; + } + + private OAuth2AuthorizationRequest authorizationRequest( + String state, + AuthChannel channel, + String transactionId + ) { + return OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("https://provider.test/oauth/authorize") + .clientId("client-id") + .redirectUri("https://api.test/login/oauth2/code/provider") + .scopes(Set.of("profile")) + .state(state) + .attributes(attributes -> { + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE, + channel); + + if (transactionId != null) { + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, + transactionId); + } + }) + .build(); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java index 89e2f57..52198a0 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java @@ -2,7 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.member.domain.Provider; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -13,6 +17,8 @@ class MobileOAuthAuthorizationRequestResolverTest { + private static final String TRANSACTION_ID = "T".repeat(43); + @Test void appleAuthorizationUsesFormPostResponseMode() { ClientRegistration apple = ClientRegistration @@ -49,7 +55,73 @@ void appleAuthorizationUsesFormPostResponseMode() { assertThat(authorization).isNotNull(); assertThat(authorization.getAdditionalParameters()) .containsEntry("response_mode", "form_post"); + assertThat(authorization.getState()).startsWith("w."); + assertThat(authorization.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE)) + .isEqualTo(AuthChannel.WEB); assertThat(authorization.getAuthorizationRequestUri()) .contains("response_mode=form_post"); } + + @Test + void mobileAuthorizationBindsFlowAndTransactionToProviderState() { + ClientRegistration kakao = ClientRegistration + .withRegistrationId("kakao") + .clientId("client-id") + .clientSecret("client-secret") + .clientAuthenticationMethod( + ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "{baseUrl}/login/oauth2/code/{registrationId}") + .scope("account_email", "profile_nickname") + .authorizationUri("https://kauth.kakao.com/oauth/authorize") + .tokenUri("https://kauth.kakao.com/oauth/token") + .userInfoUri("https://kapi.kakao.com/v2/user/me") + .userNameAttributeName("id") + .clientName("Kakao") + .build(); + MobileOAuthTransactionStore transactionStore = + mock(MobileOAuthTransactionStore.class); + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + "nalssilog-dev://auth/callback", + "A".repeat(43), + "app-state-0123456", + null, + null); + + when(transactionStore.find(TRANSACTION_ID)) + .thenReturn(Optional.of(transaction)); + + MobileOAuthAuthorizationRequestResolver resolver = + new MobileOAuthAuthorizationRequestResolver( + new InMemoryClientRegistrationRepository(kakao), + transactionStore); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/oauth2/authorization/kakao"); + + request.setServletPath("/oauth2/authorization/kakao"); + request.addParameter( + MobileOAuthRequestAttributes.TRANSACTION_PARAMETER, + TRANSACTION_ID); + + OAuth2AuthorizationRequest authorization = + resolver.resolve(request, "kakao"); + + assertThat(authorization).isNotNull(); + assertThat(authorization.getState()) + .isEqualTo(MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID)); + assertThat(authorization.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE)) + .isEqualTo(AuthChannel.MOBILE); + assertThat(authorization.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE)) + .isEqualTo(TRANSACTION_ID); + assertThat(authorization.getAuthorizationRequestUri()) + .contains("state=m." + TRANSACTION_ID); + } } diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestStoreTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestStoreTest.java new file mode 100644 index 0000000..a1f422a --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestStoreTest.java @@ -0,0 +1,103 @@ +package com.nalssilog.auth.mobile.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.ticket.AuthChannel; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import tools.jackson.databind.ObjectMapper; + +@SuppressWarnings("java:S5960") +class MobileOAuthAuthorizationRequestStoreTest { + + private static final String TRANSACTION_ID = "T".repeat(43); + private static final Duration TRANSACTION_TTL = Duration.ofMinutes(10); + + @Test + void authorizationRequestRoundTripsAndIsConsumedOnce() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations values = mock(ValueOperations.class); + AuthProperties properties = mock(AuthProperties.class); + MobileOAuthAuthorizationRequestStore store = + new MobileOAuthAuthorizationRequestStore( + redisTemplate, + new ObjectMapper(), + properties); + OAuth2AuthorizationRequest authorizationRequest = + authorizationRequest(); + + when(redisTemplate.opsForValue()).thenReturn(values); + when(properties.mobile()).thenReturn(new AuthProperties.Mobile( + List.of("nalssilog-dev://auth/callback"), + TRANSACTION_TTL, + Duration.ofSeconds(90), + "test-hmac-secret", + List.of())); + + store.save(TRANSACTION_ID, authorizationRequest); + + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + + verify(values).set( + keyCaptor.capture(), + valueCaptor.capture(), + eq(TRANSACTION_TTL)); + + when(values.get(keyCaptor.getValue())).thenReturn(valueCaptor.getValue()); + when(values.getAndDelete(keyCaptor.getValue())) + .thenReturn(valueCaptor.getValue()) + .thenReturn(null); + + OAuth2AuthorizationRequest found = store.find(TRANSACTION_ID).orElseThrow(); + + assertThat(found.getState()).isEqualTo(authorizationRequest.getState()); + assertThat(found.getClientId()).isEqualTo("client-id"); + assertThat(found.getAdditionalParameters()).containsEntry("nonce", "nonce-hash"); + assertThat(found.getAttribute(OAuth2ParameterNames.REGISTRATION_ID)) + .isEqualTo("kakao"); + assertThat(found.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE)) + .isEqualTo("MOBILE"); + assertThat(found.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE)) + .isEqualTo(TRANSACTION_ID); + + assertThat(store.take(TRANSACTION_ID)).isPresent(); + assertThat(store.take(TRANSACTION_ID)).isEmpty(); + } + + private OAuth2AuthorizationRequest authorizationRequest() { + return OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("https://kauth.kakao.com/oauth/authorize") + .clientId("client-id") + .redirectUri("https://dev-api.nalssilog.com/login/oauth2/code/kakao") + .scopes(Set.of("account_email", "profile_nickname")) + .state(MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID)) + .additionalParameters(parameters -> + parameters.put("nonce", "nonce-hash")) + .attributes(attributes -> { + attributes.put(OAuth2ParameterNames.REGISTRATION_ID, "kakao"); + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE, + AuthChannel.MOBILE); + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, + TRANSACTION_ID); + }) + .build(); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java index 8cdb282..d15537d 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java @@ -149,6 +149,92 @@ void oauthCallbackOnlyIssuesOneTimeCodeAndDoesNotCreateSession() { verifyNoInteractions(authTokenService); } + @Test + void oauthFailureReturnsErrorDirectlyToAppWithoutIssuingCode() { + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + null); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + + String callback = service.completeFailure( + "transaction", + "access_denied", + "User cancelled Kakao login") + .orElseThrow(); + + assertThat(callback) + .contains( + "error=access_denied", + "error_description=User%20cancelled%20Kakao%20login", + "state=" + STATE) + .doesNotContain("code="); + verifyNoInteractions(codeStore, authTokenService); + } + + @Test + void rejectedOAuthPrincipalReturnsErrorWithoutIssuingCode() { + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + null); + SocialAuthPrincipal wrongProvider = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.NAVER, + "provider-user", + "user@example.com", + "사용자"), + Map.of()); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + + String callback = service.complete("transaction", wrongProvider); + + assertThat(callback) + .contains( + "error=OAUTH_FAILED", + "error_description=OAuth%20authentication%20failed", + "state=" + STATE) + .doesNotContain("code="); + verifyNoInteractions(codeStore, authTokenService); + } + + @Test + void reauthenticationFailureDiscardsPendingLinkAndKeepsExistingSession() { + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN_LINK_REAUTH, + Provider.NAVER, + REDIRECT_URI, + CHALLENGE, + STATE, + "link-ticket", + 7L); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + + assertThat(service.completeFailure( + "transaction", + "access_denied", + "OAuth authorization was cancelled")).isPresent(); + + verify(ticketStore).deleteLink("link-ticket"); + verify(ticketStore).deleteLinkConsent("link-ticket"); + verifyNoInteractions(codeStore, authTokenService); + } + @Test void tokensAreCreatedOnlyAfterSuccessfulCodeExchange() { String verifier = "v".repeat(43); @@ -193,7 +279,7 @@ void tokensAreCreatedOnlyAfterSuccessfulCodeExchange() { } @Test - void loginLinkIssuesSessionForNewlyLinkedProvider() { + void loginLinkReturnsSuccessAndIssuesSessionForNewlyLinkedProvider() { String verifier = "v".repeat(43); DeviceInfo device = new DeviceInfo( "IOS · iPhone · 0.1.0", @@ -253,10 +339,9 @@ void loginLinkIssuesSessionForNewlyLinkedProvider() { MobileOAuthGrant grant = grantCaptor.getValue(); - assertThat(grant).isEqualTo(MobileOAuthGrant.linkSuccess( + assertThat(grant).isEqualTo(MobileOAuthGrant.success( 7L, - Provider.KAKAO, - true)); + Provider.KAKAO)); when(codeStore.consume( org.mockito.ArgumentMatchers.eq("one-time-code"), @@ -275,8 +360,9 @@ void loginLinkIssuesSessionForNewlyLinkedProvider() { REDIRECT_URI, device); - assertThat(result.result()).isEqualTo(MobileAuthResult.LINK_SUCCESS); + assertThat(result.result()).isEqualTo(MobileAuthResult.SUCCESS); assertThat(result.tokens()).isEqualTo(tokens); + assertThat(result.member()).isEqualTo(member); verify(authTokenService).issue( 7L, @@ -292,6 +378,110 @@ void loginLinkIssuesSessionForNewlyLinkedProvider() { verify(memberClient, never()).recordLogin(7L, Provider.NAVER); } + @Test + void legacyLoginLinkGrantAlsoReturnsSuccessWithTokens() { + String verifier = "v".repeat(43); + DeviceInfo device = new DeviceInfo( + "IOS · iPhone · 0.1.0", + "client-a.test"); + MemberInfo member = linkedMember(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(codeStore.consume( + org.mockito.ArgumentMatchers.eq("one-time-code"), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + any())) + .thenReturn(MobileOAuthGrant.linkSuccess( + 7L, + Provider.KAKAO, + true)); + when(memberClient.getMemberInfo(7L)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + MobileOAuthService.ExchangeResult result = service.exchange( + "one-time-code", + verifier, + REDIRECT_URI, + device); + + assertThat(result.result()).isEqualTo(MobileAuthResult.SUCCESS); + assertThat(result.tokens()).isEqualTo(tokens); + assertThat(result.member()).isEqualTo(member); + } + + @Test + void settingsSocialLinkKeepsSessionAndReturnsLinkSuccessWithoutTokens() { + String verifier = "v".repeat(43); + DeviceInfo device = new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "client-a.test"); + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.SETTINGS_LINK, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + 7L); + OAuthUserInfo linkedUserInfo = new OAuthUserInfo( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + null); + SocialAuthPrincipal principal = new SocialAuthPrincipal( + SocialLoginResult.newMember("user@example.com"), + linkedUserInfo, + Map.of()); + MemberInfo member = linkedMember(); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + when(memberClient.linkSocial(7L, linkedUserInfo)).thenReturn(member); + + service.complete("transaction", principal); + + ArgumentCaptor grantCaptor = + ArgumentCaptor.forClass(MobileOAuthGrant.class); + + verify(codeStore).save( + any(), + grantCaptor.capture(), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + org.mockito.ArgumentMatchers.eq(CHALLENGE)); + + MobileOAuthGrant grant = grantCaptor.getValue(); + + assertThat(grant).isEqualTo(MobileOAuthGrant.linkSuccess( + 7L, + Provider.KAKAO, + false)); + + when(codeStore.consume( + org.mockito.ArgumentMatchers.eq("one-time-code"), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + any())).thenReturn(grant); + when(memberClient.getMemberInfo(7L)).thenReturn(member); + + MobileOAuthService.ExchangeResult result = service.exchange( + "one-time-code", + verifier, + REDIRECT_URI, + device); + + assertThat(result.result()).isEqualTo(MobileAuthResult.LINK_SUCCESS); + assertThat(result.tokens()).isNull(); + assertThat(result.member()).isEqualTo(member); + verifyNoInteractions(authTokenService); + verify(memberClient, never()).recordLogin(any(), any()); + } + @Test void unregisteredRedirectIsRejectedBeforeOAuthStateIsCreated() { NalssiLogException exception = catchThrowableOfType( diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthSessionlessCallbackTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthSessionlessCallbackTest.java new file mode 100644 index 0000000..7319cc3 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthSessionlessCallbackTest.java @@ -0,0 +1,189 @@ +package com.nalssilog.auth.mobile.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.ticket.AuthChannel; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.user.OAuth2User; +import tools.jackson.databind.ObjectMapper; + +@SuppressWarnings("java:S5960") +class MobileOAuthSessionlessCallbackTest { + + private static final String TRANSACTION_ID = "T".repeat(43); + private static final String MOBILE_STATE = + MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID); + private static final Duration TRANSACTION_TTL = Duration.ofMinutes(10); + + @Test + void springOAuthFilterAuthenticatesCallbackWithoutHttpSession() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations values = mock(ValueOperations.class); + AuthProperties properties = mock(AuthProperties.class); + AtomicReference storedValue = new AtomicReference<>(); + MobileOAuthAuthorizationRequestStore store = + new MobileOAuthAuthorizationRequestStore( + redisTemplate, + new ObjectMapper(), + properties); + MobileOAuthAuthorizationRequestRepository repository = + new MobileOAuthAuthorizationRequestRepository(store); + ClientRegistration registration = kakaoRegistration(); + InMemoryClientRegistrationRepository registrations = + new InMemoryClientRegistrationRepository(registration); + OAuth2AuthorizedClientRepository authorizedClients = + mock(OAuth2AuthorizedClientRepository.class); + AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + OAuth2LoginAuthenticationFilter filter = new OAuth2LoginAuthenticationFilter( + registrations, + authorizedClients, + "/login/oauth2/code/*"); + + when(redisTemplate.opsForValue()).thenReturn(values); + when(properties.mobile()).thenReturn(new AuthProperties.Mobile( + List.of("nalssilog-dev://auth/callback"), + TRANSACTION_TTL, + Duration.ofSeconds(90), + "test-hmac-secret", + List.of())); + doAnswer(invocation -> { + storedValue.set(invocation.getArgument(1)); + return null; + }).when(values).set(anyString(), anyString(), eq(TRANSACTION_TTL)); + when(values.getAndDelete(anyString())) + .thenAnswer(invocation -> storedValue.getAndSet(null)); + + OAuth2AuthorizationRequest authorizationRequest = authorizationRequest(); + MockHttpServletRequest authorization = new MockHttpServletRequest(); + + repository.saveAuthorizationRequest( + authorizationRequest, + authorization, + new MockHttpServletResponse()); + + assertThat(authorization.getSession(false)).isNull(); + + OAuth2User principal = mock(OAuth2User.class); + + when(principal.getName()).thenReturn("kakao-user"); + when(authenticationManager.authenticate(any(Authentication.class))) + .thenAnswer(invocation -> { + OAuth2LoginAuthenticationToken requestToken = invocation.getArgument(0); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "provider-access-token", + Instant.now(), + Instant.now().plusSeconds(300)); + + return new OAuth2LoginAuthenticationToken( + registration, + requestToken.getAuthorizationExchange(), + principal, + List.of(), + accessToken); + }); + + filter.setAuthorizationRequestRepository(repository); + filter.setAuthenticationManager(authenticationManager); + + MockHttpServletRequest callback = callback(); + MockHttpServletResponse callbackResponse = new MockHttpServletResponse(); + Authentication authenticationResult = filter.attemptAuthentication( + callback, + callbackResponse); + + assertThat(authenticationResult).isInstanceOf(OAuth2AuthenticationToken.class); + assertThat(callback.getSession(false)).isNull(); + assertThat(MobileOAuthRequestAttributes.channel(callback)) + .contains(AuthChannel.MOBILE); + assertThat(MobileOAuthRequestAttributes.transactionId(callback)) + .contains(TRANSACTION_ID); + assertThat(storedValue.get()).isNull(); + verify(authorizedClients).saveAuthorizedClient( + any(), + any(), + eq(callback), + eq(callbackResponse)); + } + + private MockHttpServletRequest callback() { + MockHttpServletRequest callback = new MockHttpServletRequest( + "GET", + "/login/oauth2/code/kakao"); + + callback.setScheme("https"); + callback.setServerName("dev-api.nalssilog.com"); + callback.setServerPort(443); + callback.addParameter(OAuth2ParameterNames.CODE, "provider-code"); + callback.addParameter(OAuth2ParameterNames.STATE, MOBILE_STATE); + + return callback; + } + + private OAuth2AuthorizationRequest authorizationRequest() { + return OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("https://kauth.kakao.com/oauth/authorize") + .clientId("client-id") + .redirectUri("https://dev-api.nalssilog.com/login/oauth2/code/kakao") + .scopes(Set.of("account_email", "profile_nickname")) + .state(MOBILE_STATE) + .attributes(attributes -> { + attributes.put(OAuth2ParameterNames.REGISTRATION_ID, "kakao"); + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_CHANNEL_ATTRIBUTE, + AuthChannel.MOBILE); + attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, + TRANSACTION_ID); + }) + .build(); + } + + private ClientRegistration kakaoRegistration() { + return ClientRegistration + .withRegistrationId("kakao") + .clientId("client-id") + .clientSecret("client-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("account_email", "profile_nickname") + .authorizationUri("https://kauth.kakao.com/oauth/authorize") + .tokenUri("https://kauth.kakao.com/oauth/token") + .userInfoUri("https://kapi.kakao.com/v2/user/me") + .userNameAttributeName("id") + .clientName("Kakao") + .build(); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java index d85d8a1..6598600 100644 --- a/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java @@ -2,8 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.web.AuthCookieManager; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -14,10 +20,14 @@ @SuppressWarnings("java:S5960") // 표준 src/test 소스의 AssertJ 검증을 운영 코드 assertion으로 오인하는 경고. class OAuth2LoginFailureHandlerTest { + private static final String TRANSACTION_ID = "T".repeat(43); + @Test void missingEmailRedirectsWithDedicatedFailureCode() throws Exception { OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( - mock(MobileOAuthService.class)); + mock(MobileOAuthService.class), + mock(WebOAuthService.class), + mock(AuthCookieManager.class)); ReflectionTestUtils.setField(handler, "frontendBaseUrl", "https://dev.nalssilog.com"); @@ -34,4 +44,110 @@ void missingEmailRedirectsWithDedicatedFailureCode() throws Exception { assertThat(response.getRedirectedUrl()).isEqualTo( "https://dev.nalssilog.com/auth/callback?result=FAILED&code=OAUTH_EMAIL_REQUIRED"); } + + @Test + void webCancellationClearsPendingAuthenticationButKeepsLoginSession() throws Exception { + MobileOAuthService mobileOAuthService = mock(MobileOAuthService.class); + WebOAuthService webOAuthService = mock(WebOAuthService.class); + AuthCookieManager cookieManager = mock(AuthCookieManager.class); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( + mobileOAuthService, + webOAuthService, + cookieManager); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(cookieManager.readSignupTicket(request)) + .thenReturn(Optional.of("signup-ticket")); + when(cookieManager.readLinkTicket(request)) + .thenReturn(Optional.of("link-ticket")); + when(cookieManager.readLinkIntent(request)) + .thenReturn(Optional.of("link-intent")); + ReflectionTestUtils.setField(handler, "frontendBaseUrl", "https://dev.nalssilog.com"); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error("access_denied"))); + + assertThat(response.getRedirectedUrl()).isEqualTo( + "https://dev.nalssilog.com/auth/callback?result=FAILED&code=OAUTH_CANCELLED"); + verify(webOAuthService).cancelPendingAuthentication( + Optional.of("signup-ticket"), + Optional.of("link-ticket"), + Optional.of("link-intent")); + verify(cookieManager).clearSignupTicketCookie(response); + verify(cookieManager).clearLinkTicketCookie(response); + verify(cookieManager).clearLinkIntentCookie(response); + verify(cookieManager, never()).clearAuthCookies(response); + } + + @Test + void mobileFailureReturnsToDeepLinkWithoutSessionCookie() throws Exception { + MobileOAuthService mobileOAuthService = mock(MobileOAuthService.class); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( + mobileOAuthService, + mock(WebOAuthService.class), + mock(AuthCookieManager.class)); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + String callback = "nalssilog-dev://auth/callback" + + "?error=authorization_request_not_found" + + "&error_description=OAuth%20authentication%20failed" + + "&state=app-state"; + + request.addParameter( + "state", + MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID)); + + when(mobileOAuthService.completeFailure( + TRANSACTION_ID, + "authorization_request_not_found", + "OAuth authentication failed")) + .thenReturn(Optional.of(callback)); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException( + new OAuth2Error("authorization_request_not_found"))); + + assertThat(response.getRedirectedUrl()).isEqualTo(callback); + } + + @Test + void expiredMobileFailureNeverFallsBackToWebFrontend() throws Exception { + MobileOAuthService mobileOAuthService = mock(MobileOAuthService.class); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( + mobileOAuthService, + mock(WebOAuthService.class), + mock(AuthCookieManager.class)); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + ReflectionTestUtils.setField( + handler, + "frontendBaseUrl", + "https://preview.vercel.app"); + request.addParameter( + "state", + MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID)); + + when(mobileOAuthService.completeFailure( + TRANSACTION_ID, + "authorization_request_not_found", + "OAuth authentication failed")) + .thenReturn(Optional.empty()); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException( + new OAuth2Error("authorization_request_not_found"))); + + assertThat(response.getStatus()).isEqualTo(410); + assertThat(response.getErrorMessage()).isNull(); + assertThat(response.getRedirectedUrl()).isNull(); + assertThat(response.getHeader("Location")).isNull(); + } } diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandlerTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandlerTest.java new file mode 100644 index 0000000..2b9ffb4 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandlerTest.java @@ -0,0 +1,52 @@ +package com.nalssilog.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.web.AuthCookieManager; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; + +@SuppressWarnings("java:S5960") +class OAuth2LoginSuccessHandlerTest { + + private static final String TRANSACTION_ID = "T".repeat(43); + + @Test + void mobileSuccessUsesDeepLinkFlowWithoutReadingWebCookies() throws Exception { + AuthCookieManager cookieManager = mock(AuthCookieManager.class); + DeviceInfoResolver deviceInfoResolver = mock(DeviceInfoResolver.class); + MobileOAuthService mobileOAuthService = mock(MobileOAuthService.class); + WebOAuthService webOAuthService = mock(WebOAuthService.class); + OAuth2LoginSuccessHandler handler = new OAuth2LoginSuccessHandler( + cookieManager, + deviceInfoResolver, + mobileOAuthService, + webOAuthService); + SocialPrincipal principal = mock(SocialPrincipal.class); + Authentication authentication = mock(Authentication.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + String callback = "nalssilog-dev://auth/callback?code=one-time-code&state=app-state"; + + request.addParameter( + "state", + MobileOAuthRequestAttributes.mobileState(TRANSACTION_ID)); + + when(authentication.getPrincipal()).thenReturn(principal); + when(mobileOAuthService.complete(TRANSACTION_ID, principal)) + .thenReturn(callback); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertThat(response.getRedirectedUrl()).isEqualTo(callback); + verifyNoInteractions(cookieManager, deviceInfoResolver, webOAuthService); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java index 721abdd..87b3d4b 100644 --- a/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java @@ -37,6 +37,19 @@ class WebOAuthServiceTest { memberClient, mock(AuthProperties.class)); + @Test + void cancellationDeletesEveryPendingAuthenticationTicket() { + service.cancelPendingAuthentication( + Optional.of("signup-ticket"), + Optional.of("link-ticket"), + Optional.of("link-intent")); + + verify(ticketStore).deleteSignup("signup-ticket"); + verify(ticketStore).deleteLink("link-ticket"); + verify(ticketStore).deleteLinkConsent("link-ticket"); + verify(ticketStore).deleteLinkIntent("link-intent"); + } + @Test void loginLinkIssuesSessionForNewlyLinkedProvider() { DeviceInfo device = new DeviceInfo("Chrome", "client-a.test"); diff --git a/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java b/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java index 2f74f2d..081b767 100644 --- a/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java @@ -3,14 +3,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.member.MemberClient; import com.nalssilog.auth.web.AuthCookieManager; import com.nalssilog.common.security.VerifiedRequestCredentials; import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.MemberRole; import com.nalssilog.member.domain.Provider; import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,12 +33,14 @@ class JwtAuthenticationFilterTest { mock(RefreshTokenStore.class); private final JwtTokenProvider tokenProvider = new JwtTokenProvider(TestAuthProperties.create()); + private final MemberClient memberClient = mock(MemberClient.class); private final JwtAuthenticationFilter filter = new JwtAuthenticationFilter( cookieManager, tokenProvider, refreshTokenStore, - new ApiAuthenticationEntryPoint(new ObjectMapper())); + new ApiAuthenticationEntryPoint(new ObjectMapper()), + memberClient); @BeforeEach @AfterEach @@ -104,6 +110,27 @@ void malformedAuthorizationNeverFallsBackToCookieAuthentication() verifyNoInteractions(cookieManager); } + @Test + void adminRequestLoadsCurrentOperationalRoleFromMemberStore() throws Exception { + when(memberClient.findRole(7L)).thenReturn(Optional.of(MemberRole.ADMIN)); + String accessToken = tokenProvider.createAccessToken( + 7L, MemberStatus.ACTIVE, Provider.KAKAO, "session-7"); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/api/admin/report-flags"); + + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + + filter.doFilter(request, new MockHttpServletResponse(), (_, _) -> { + var authorities = SecurityContextHolder.getContext() + .getAuthentication() + .getAuthorities(); + + assertThat(authorities) + .extracting(authority -> authority.getAuthority()) + .contains("ROLE_MEMBER", "ROLE_MODERATOR", "ROLE_ADMIN"); + }); + } + @Test void expiredBearerReturnsTheStableExpiredCode() throws Exception { JwtTokenProvider expiredTokenProvider = @@ -114,7 +141,8 @@ void expiredBearerReturnsTheStableExpiredCode() throws Exception { cookieManager, expiredTokenProvider, refreshTokenStore, - new ApiAuthenticationEntryPoint(new ObjectMapper())); + new ApiAuthenticationEntryPoint(new ObjectMapper()), + mock(MemberClient.class)); String expiredToken = expiredTokenProvider.createAccessToken( 7L, MemberStatus.ACTIVE, diff --git a/config b/config index f383ce4..7a6434b 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit f383ce419e3a4498fa1d5fa3c3e41c73b11dde78 +Subproject commit 7a6434b8007cbef155e25ac78765f3d3361ff26c diff --git a/gradle.properties b/gradle.properties index 7be3162..ecdb09b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -appVersion=0.2.2 +appVersion=0.3.0 diff --git a/member/src/main/java/com/nalssilog/member/api/AdminMemberController.java b/member/src/main/java/com/nalssilog/member/api/AdminMemberController.java new file mode 100644 index 0000000..719e9a9 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/api/AdminMemberController.java @@ -0,0 +1,47 @@ +package com.nalssilog.member.api; + +import com.nalssilog.member.api.dto.AdminMemberRoleResponse; +import com.nalssilog.member.api.dto.UpdateMemberRoleRequest; +import com.nalssilog.member.api.dto.AdminMemberRoleChangePageResponse; +import com.nalssilog.member.application.MemberAccountService; +import com.nalssilog.member.repository.MemberRoleChangeJpaRepository; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.data.domain.PageRequest; +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.RestController; + +@RestController +@RequestMapping("/api/admin/members") +@RequiredArgsConstructor +public class AdminMemberController { + + private final MemberAccountService memberAccountService; + private final MemberRoleChangeJpaRepository roleChangeRepository; + + @PatchMapping("/{memberId}/role") + public AdminMemberRoleResponse changeRole( + @PathVariable Long memberId, + @AuthenticationPrincipal Long adminMemberId, + @Valid @RequestBody UpdateMemberRoleRequest request + ) { + return AdminMemberRoleResponse.from( + memberAccountService.changeRole(memberId, request.role(), adminMemberId)); + } + + @GetMapping("/role-changes") + public AdminMemberRoleChangePageResponse roleChanges( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return AdminMemberRoleChangePageResponse.from( + roleChangeRepository.findAllByOrderByCreatedAtDesc(PageRequest.of( + Math.max(page, 0), Math.max(1, Math.min(size, 100))))); + } +} diff --git a/member/src/main/java/com/nalssilog/member/api/MemberController.java b/member/src/main/java/com/nalssilog/member/api/MemberController.java index b4790e4..1b45135 100644 --- a/member/src/main/java/com/nalssilog/member/api/MemberController.java +++ b/member/src/main/java/com/nalssilog/member/api/MemberController.java @@ -50,8 +50,12 @@ public MemberMeResponse me( } @GetMapping("/{id}") - public MemberPublicProfileResponse publicProfile(@PathVariable Long id) { - return MemberPublicProfileResponse.from(memberProfileService.getPublicProfile(id)); + public MemberPublicProfileResponse publicProfile( + @PathVariable Long id, + @AuthenticationPrincipal Long viewerMemberId + ) { + return MemberPublicProfileResponse.from( + memberProfileService.getPublicProfile(viewerMemberId, id)); } @PatchMapping("/me/name") diff --git a/member/src/main/java/com/nalssilog/member/api/dto/AdminMemberRoleChangePageResponse.java b/member/src/main/java/com/nalssilog/member/api/dto/AdminMemberRoleChangePageResponse.java new file mode 100644 index 0000000..4869b4d --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/api/dto/AdminMemberRoleChangePageResponse.java @@ -0,0 +1,45 @@ +package com.nalssilog.member.api.dto; + +import com.nalssilog.member.domain.MemberRole; +import com.nalssilog.member.domain.MemberRoleChange; +import java.time.Instant; +import java.util.List; +import org.springframework.data.domain.Page; + +public record AdminMemberRoleChangePageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + + public record Item( + String id, + String targetMemberId, + String changedByMemberId, + MemberRole previousRole, + MemberRole newRole, + Instant createdAt + ) { + } + + public static AdminMemberRoleChangePageResponse from(Page changes) { + List items = changes.getContent().stream() + .map(change -> new Item( + String.valueOf(change.getId()), + String.valueOf(change.getTargetMemberId()), + String.valueOf(change.getChangedByMemberId()), + change.getPreviousRole(), + change.getNewRole(), + change.getCreatedAt())) + .toList(); + + return new AdminMemberRoleChangePageResponse( + items, + changes.getNumber(), + changes.getSize(), + changes.getTotalElements(), + changes.getTotalPages()); + } +} diff --git a/member/src/main/java/com/nalssilog/member/api/dto/AdminMemberRoleResponse.java b/member/src/main/java/com/nalssilog/member/api/dto/AdminMemberRoleResponse.java new file mode 100644 index 0000000..0340731 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/api/dto/AdminMemberRoleResponse.java @@ -0,0 +1,11 @@ +package com.nalssilog.member.api.dto; + +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.MemberRole; + +public record AdminMemberRoleResponse(String memberId, MemberRole role) { + + public static AdminMemberRoleResponse from(MemberInfo member) { + return new AdminMemberRoleResponse(String.valueOf(member.id()), member.role()); + } +} diff --git a/member/src/main/java/com/nalssilog/member/api/dto/MemberMeResponse.java b/member/src/main/java/com/nalssilog/member/api/dto/MemberMeResponse.java index f8683e0..fd6636e 100644 --- a/member/src/main/java/com/nalssilog/member/api/dto/MemberMeResponse.java +++ b/member/src/main/java/com/nalssilog/member/api/dto/MemberMeResponse.java @@ -3,6 +3,7 @@ import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; import com.nalssilog.member.domain.Provider; +import com.nalssilog.member.domain.MemberRole; import java.util.List; /** @@ -17,7 +18,8 @@ public record MemberMeResponse( String email, Avatar avatar, List connectedProviders, - Provider currentProvider + Provider currentProvider, + MemberRole role ) { public record Avatar(AvatarType type, String value) { @@ -31,7 +33,8 @@ public static MemberMeResponse from(MemberInfo member, Provider currentProvider) member.email(), new Avatar(member.avatarType(), member.avatarValue()), member.connectedProviders(), - currentProvider + currentProvider, + member.role() ); } } diff --git a/member/src/main/java/com/nalssilog/member/api/dto/UpdateMemberRoleRequest.java b/member/src/main/java/com/nalssilog/member/api/dto/UpdateMemberRoleRequest.java new file mode 100644 index 0000000..056ce1d --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/api/dto/UpdateMemberRoleRequest.java @@ -0,0 +1,10 @@ +package com.nalssilog.member.api.dto; + +import com.nalssilog.member.domain.MemberRole; +import jakarta.validation.constraints.NotNull; + +public record UpdateMemberRoleRequest( + @NotNull(message = "변경할 회원 권한이 필요합니다.") + MemberRole role +) { +} diff --git a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java index 54eed1e..933b3d3 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java @@ -16,10 +16,14 @@ import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; +import com.nalssilog.member.domain.MemberRole; +import com.nalssilog.member.domain.MemberRoleChange; +import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.member.domain.Provider; import com.nalssilog.member.domain.SocialAccount; import com.nalssilog.member.domain.event.MemberWithdrawnEvent; import com.nalssilog.member.repository.MemberRepository; +import com.nalssilog.member.repository.MemberRoleChangeJpaRepository; import com.nalssilog.member.repository.SocialAccountRepository; import lombok.RequiredArgsConstructor; @@ -37,6 +41,7 @@ public class MemberAccountService { private final MemberRepository memberRepository; private final SocialAccountRepository socialAccountRepository; private final ApplicationEventPublisher eventPublisher; + private final MemberRoleChangeJpaRepository memberRoleChangeRepository; private static RuntimeException translateSocialLinkConflict( DataIntegrityViolationException exception @@ -152,6 +157,36 @@ public Optional findMemberInfo(Long memberId) { return memberRepository.findMemberInfo(memberId); } + public Optional findRole(Long memberId) { + return memberRepository.findRole(memberId); + } + + @Transactional + public MemberInfo changeRole(Long memberId, MemberRole role, Long changedByMemberId) { + Member member = memberRepository.getMember(memberId); + MemberRole previousRole = member.getRole(); + + if (member.getStatus() != MemberStatus.ACTIVE) { + throw new NalssiLogException(MemberErrorCode.ROLE_TARGET_NOT_ACTIVE); + } + + if (previousRole == MemberRole.ADMIN + && role != MemberRole.ADMIN + && memberRepository.countByRole(MemberRole.ADMIN) <= 1) { + throw new NalssiLogException(MemberErrorCode.LAST_ADMIN_REQUIRED); + } + + if (previousRole == role) { + return memberRepository.getMemberInfo(memberId); + } + + member.changeRole(role); + memberRoleChangeRepository.save(MemberRoleChange.record( + memberId, changedByMemberId, previousRole, role)); + + return memberRepository.getMemberInfo(memberId); + } + public List findMemberSummaries(Collection memberIds) { return memberRepository.findSummariesByIds(memberIds); } diff --git a/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java b/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java index c15e220..e1e2afd 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java @@ -27,6 +27,7 @@ public class MemberProfileService { private final MemberRepository memberRepository; private final SocialAccountRepository socialAccountRepository; private final AvatarStorageClient avatarStorageClient; + private final MemberVisibilityPolicy visibilityPolicy; public boolean isNicknameAvailable(String nickname) { return !memberRepository.existsByNickname(nickname.strip()); @@ -85,10 +86,16 @@ public MemberInfo getMe(Long memberId) { return memberRepository.getMemberInfo(memberId); } - public MemberInfo getPublicProfile(Long memberId) { - return memberRepository.findMemberInfo(memberId) + public MemberInfo getPublicProfile(Long viewerMemberId, Long memberId) { + MemberInfo profile = memberRepository.findMemberInfo(memberId) .filter(member -> member.status() == MemberStatus.ACTIVE) .orElseThrow(() -> new NalssiLogException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (!visibilityPolicy.canView(viewerMemberId, memberId)) { + throw new NalssiLogException(MemberErrorCode.MEMBER_NOT_FOUND); + } + + return profile; } public List getSocialAccounts(Long memberId) { diff --git a/member/src/main/java/com/nalssilog/member/application/MemberVisibilityPolicy.java b/member/src/main/java/com/nalssilog/member/application/MemberVisibilityPolicy.java new file mode 100644 index 0000000..e9c239e --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/application/MemberVisibilityPolicy.java @@ -0,0 +1,11 @@ +package com.nalssilog.member.application; + +/** + * 로그인한 조회자와 공개 프로필 대상 회원 사이의 노출 정책. + * 차단 데이터의 소유 모듈이 구현해 member 모듈이 차단 저장소에 직접 의존하지 않게 한다. + */ +@FunctionalInterface +public interface MemberVisibilityPolicy { + + boolean canView(Long viewerMemberId, Long targetMemberId); +} diff --git a/member/src/main/java/com/nalssilog/member/application/dto/MemberInfo.java b/member/src/main/java/com/nalssilog/member/application/dto/MemberInfo.java index b67eba1..bafe465 100644 --- a/member/src/main/java/com/nalssilog/member/application/dto/MemberInfo.java +++ b/member/src/main/java/com/nalssilog/member/application/dto/MemberInfo.java @@ -3,6 +3,7 @@ import com.nalssilog.member.domain.AvatarType; import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.MemberRole; import com.nalssilog.member.domain.Provider; import com.nalssilog.member.domain.SocialAccount; import java.util.Comparator; @@ -21,9 +22,26 @@ public record MemberInfo( String avatarValue, MemberStatus status, Provider lastLoginProvider, - List connectedProviders + List connectedProviders, + MemberRole role ) { + /** 기존 내부 호출·테스트와의 소스 호환을 위한 일반 회원 기본 생성자. */ + public MemberInfo( + Long id, + String nickname, + String name, + String email, + AvatarType avatarType, + String avatarValue, + MemberStatus status, + Provider lastLoginProvider, + List connectedProviders + ) { + this(id, nickname, name, email, avatarType, avatarValue, status, + lastLoginProvider, connectedProviders, MemberRole.MEMBER); + } + public static MemberInfo of(Member member, List accounts) { Provider lastLoginProvider = accounts.stream() .filter(account -> account.getLastLoginAt() != null) @@ -44,7 +62,8 @@ public static MemberInfo of(Member member, List accounts) { member.getAvatarValue(), member.getStatus(), lastLoginProvider, - connectedProviders + connectedProviders, + member.getRole() ); } } diff --git a/member/src/main/java/com/nalssilog/member/domain/Member.java b/member/src/main/java/com/nalssilog/member/domain/Member.java index d4ff180..bafb65e 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Member.java +++ b/member/src/main/java/com/nalssilog/member/domain/Member.java @@ -60,6 +60,10 @@ public class Member extends BaseTimeEntity { @Column(name = "status", nullable = false, length = 20) private MemberStatus status; + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false, length = 20) + private MemberRole role; + @Column(name = "withdrawn_at", nullable = true) private Instant withdrawnAt; @@ -73,6 +77,7 @@ public static Member register(String email, String name, String nickname) { member.avatarType = AvatarType.PRESET; member.avatarValue = DEFAULT_AVATAR_PRESETS.get(ThreadLocalRandom.current().nextInt(DEFAULT_AVATAR_PRESETS.size())); member.status = MemberStatus.ACTIVE; + member.role = MemberRole.MEMBER; return member; } @@ -94,6 +99,14 @@ public void changeAvatar(AvatarType avatarType, String avatarValue) { this.avatarValue = avatarType == AvatarType.DEFAULT ? null : avatarValue; } + public void changeRole(MemberRole role) { + if (role == null) { + throw new IllegalArgumentException("Member role is required."); + } + + this.role = role; + } + /** 탈퇴: WITHDRAWN + 개인정보 익명화(닉네임도 null 로 비워 재사용 허용). 제보는 별도 익명화. */ public void withdraw() { if (this.status == MemberStatus.WITHDRAWN) { diff --git a/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java b/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java index d0e3ea1..79ee03f 100644 --- a/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java +++ b/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java @@ -28,7 +28,9 @@ public enum MemberErrorCode implements ErrorCode { IMAGE_NOT_FOUND("IMAGE_NOT_FOUND", "업로드된 이미지를 찾을 수 없습니다.", HttpStatus.BAD_REQUEST), TERMS_NOT_AGREED("TERMS_NOT_AGREED", "필수 약관에 모두 동의해야 합니다.", HttpStatus.BAD_REQUEST), FEEDBACK_RATE_LIMITED("FEEDBACK_RATE_LIMITED", "피드백을 너무 자주 보냈습니다. 잠시 후 다시 시도해 주세요.", - HttpStatus.TOO_MANY_REQUESTS); + HttpStatus.TOO_MANY_REQUESTS), + ROLE_TARGET_NOT_ACTIVE("ROLE_TARGET_NOT_ACTIVE", "활성 회원의 권한만 변경할 수 있습니다.", HttpStatus.CONFLICT), + LAST_ADMIN_REQUIRED("LAST_ADMIN_REQUIRED", "서비스에는 최소 한 명의 관리자가 필요합니다.", HttpStatus.CONFLICT); private final String code; private final String message; diff --git a/member/src/main/java/com/nalssilog/member/domain/MemberRole.java b/member/src/main/java/com/nalssilog/member/domain/MemberRole.java new file mode 100644 index 0000000..3a1f4a6 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/domain/MemberRole.java @@ -0,0 +1,8 @@ +package com.nalssilog.member.domain; + +/** 회원 생명주기 상태와 분리된 서비스 운영 권한. */ +public enum MemberRole { + MEMBER, + MODERATOR, + ADMIN +} diff --git a/member/src/main/java/com/nalssilog/member/domain/MemberRoleChange.java b/member/src/main/java/com/nalssilog/member/domain/MemberRoleChange.java new file mode 100644 index 0000000..ad8c151 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/domain/MemberRoleChange.java @@ -0,0 +1,60 @@ +package com.nalssilog.member.domain; + +import com.nalssilog.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "member_role_change", + indexes = { + @Index(name = "idx_member_role_change_target", columnList = "target_member_id, created_at"), + @Index(name = "idx_member_role_change_admin", columnList = "changed_by_member_id, created_at") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberRoleChange extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "target_member_id", nullable = false) + private Long targetMemberId; + + @Column(name = "changed_by_member_id", nullable = false) + private Long changedByMemberId; + + @Enumerated(EnumType.STRING) + @Column(name = "previous_role", nullable = false, length = 20) + private MemberRole previousRole; + + @Enumerated(EnumType.STRING) + @Column(name = "new_role", nullable = false, length = 20) + private MemberRole newRole; + + public static MemberRoleChange record( + Long targetMemberId, + Long changedByMemberId, + MemberRole previousRole, + MemberRole newRole + ) { + MemberRoleChange change = new MemberRoleChange(); + + change.targetMemberId = targetMemberId; + change.changedByMemberId = changedByMemberId; + change.previousRole = previousRole; + change.newRole = newRole; + + return change; + } +} diff --git a/member/src/main/java/com/nalssilog/member/repository/MemberJpaRepository.java b/member/src/main/java/com/nalssilog/member/repository/MemberJpaRepository.java index 717f746..ccbb8d9 100644 --- a/member/src/main/java/com/nalssilog/member/repository/MemberJpaRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/MemberJpaRepository.java @@ -2,6 +2,7 @@ import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.MemberRole; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,6 +11,8 @@ */ public interface MemberJpaRepository extends JpaRepository { + long countByRole(MemberRole role); + boolean existsByNickname(String nickname); boolean existsByNicknameAndIdNot(String nickname, Long id); diff --git a/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java b/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java index 7fe430f..eb3ffba 100644 --- a/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java @@ -6,6 +6,7 @@ import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.MemberRole; import com.nalssilog.member.domain.SocialAccount; import java.util.Collection; import java.util.List; @@ -38,6 +39,15 @@ public Optional findMemberInfo(Long memberId) { .map(this::toInfo); } + public Optional findRole(Long memberId) { + return memberJpaRepository.findById(memberId) + .map(Member::getRole); + } + + public long countByRole(MemberRole role) { + return memberJpaRepository.countByRole(role); + } + public Optional findMemberInfoByEmail(String email) { return memberJpaRepository.findFirstByEmailAndStatusNot(email, MemberStatus.WITHDRAWN) .map(this::toInfo); diff --git a/member/src/main/java/com/nalssilog/member/repository/MemberRoleChangeJpaRepository.java b/member/src/main/java/com/nalssilog/member/repository/MemberRoleChangeJpaRepository.java new file mode 100644 index 0000000..d6eb973 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/repository/MemberRoleChangeJpaRepository.java @@ -0,0 +1,11 @@ +package com.nalssilog.member.repository; + +import com.nalssilog.member.domain.MemberRoleChange; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRoleChangeJpaRepository extends JpaRepository { + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java index c41cbbb..6394de9 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java @@ -13,9 +13,11 @@ import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.MemberRole; import com.nalssilog.member.domain.Provider; import com.nalssilog.member.domain.SocialAccount; import com.nalssilog.member.repository.MemberRepository; +import com.nalssilog.member.repository.MemberRoleChangeJpaRepository; import com.nalssilog.member.repository.SocialAccountRepository; import java.sql.SQLException; import java.util.Optional; @@ -30,10 +32,42 @@ class MemberAccountServiceTest { private final MemberRepository memberRepository = mock(MemberRepository.class); private final SocialAccountRepository socialAccountRepository = mock(SocialAccountRepository.class); + private final MemberRoleChangeJpaRepository roleChangeRepository = + mock(MemberRoleChangeJpaRepository.class); private final MemberAccountService service = new MemberAccountService( memberRepository, socialAccountRepository, - mock(ApplicationEventPublisher.class)); + mock(ApplicationEventPublisher.class), + roleChangeRepository); + + @Test + void lastAdminCannotBeDemoted() { + Member admin = Member.register("admin@example.com", "관리자", "관리자01"); + + admin.changeRole(MemberRole.ADMIN); + when(memberRepository.getMember(7L)).thenReturn(admin); + when(memberRepository.countByRole(MemberRole.ADMIN)).thenReturn(1L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.changeRole(7L, MemberRole.MEMBER, 9L)); + + assertThat(exception.getErrorCode()).isEqualTo(MemberErrorCode.LAST_ADMIN_REQUIRED); + assertThat(admin.getRole()).isEqualTo(MemberRole.ADMIN); + verify(roleChangeRepository, never()).save(any()); + } + + @Test + void roleChangeIsPersistedAsAuditRecord() { + Member member = Member.register("user@example.com", "회원", "회원01"); + + when(memberRepository.getMember(7L)).thenReturn(member); + + service.changeRole(7L, MemberRole.MODERATOR, 9L); + + assertThat(member.getRole()).isEqualTo(MemberRole.MODERATOR); + verify(roleChangeRepository).save(any()); + } @Test void resolvingExistingSocialIdentityDoesNotRecordServiceLogin() { diff --git a/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java index ec70d04..f65e07f 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java @@ -28,10 +28,12 @@ class MemberProfileServiceTest { private final MemberRepository memberRepository = mock(MemberRepository.class); private final SocialAccountRepository socialAccountRepository = mock(SocialAccountRepository.class); private final AvatarStorageClient avatarStorageClient = mock(AvatarStorageClient.class); + private final MemberVisibilityPolicy visibilityPolicy = mock(MemberVisibilityPolicy.class); private final MemberProfileService service = new MemberProfileService( memberRepository, socialAccountRepository, - avatarStorageClient + avatarStorageClient, + visibilityPolicy ); @Test @@ -90,4 +92,41 @@ void unlinksProviderThatIsNotUsedByCurrentLogin() { verify(socialAccountRepository).delete(linked); } + + @Test + void hidesActivePublicProfileWhenBlockRelationExistsInEitherDirection() { + MemberInfo profile = activeProfile(7L); + + when(memberRepository.findMemberInfo(7L)).thenReturn(Optional.of(profile)); + when(visibilityPolicy.canView(8L, 7L)).thenReturn(false); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.getPublicProfile(8L, 7L)); + + assertThat(exception.getErrorCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + void returnsActivePublicProfileWhenNoBlockRelationExists() { + MemberInfo profile = activeProfile(7L); + + when(memberRepository.findMemberInfo(7L)).thenReturn(Optional.of(profile)); + when(visibilityPolicy.canView(8L, 7L)).thenReturn(true); + + assertThat(service.getPublicProfile(8L, 7L)).isSameAs(profile); + } + + private MemberInfo activeProfile(Long memberId) { + return new MemberInfo( + memberId, + "이웃", + "이름", + "user@example.com", + AvatarType.PRESET, + "avatar-01", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.KAKAO)); + } } diff --git a/report/build.gradle b/report/build.gradle index ba4d6aa..1a99d6c 100644 --- a/report/build.gradle +++ b/report/build.gradle @@ -5,6 +5,7 @@ dependencies { // 컨트롤러에서 인증된 memberId(@AuthenticationPrincipal)를 읽기 위한 최소 의존. 설정은 auth 소유. implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // 이미지 presigned URL 발급 — R2/S3 인프라는 storage 모듈이 공유 제공. implementation project(':storage') diff --git a/report/src/main/java/com/nalssilog/report/api/AdminReportModerationController.java b/report/src/main/java/com/nalssilog/report/api/AdminReportModerationController.java new file mode 100644 index 0000000..cedd8d6 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/AdminReportModerationController.java @@ -0,0 +1,83 @@ +package com.nalssilog.report.api; + +import com.nalssilog.report.api.dto.AdminActorRestrictionResponse; +import com.nalssilog.report.api.dto.AdminModerateReportRequest; +import com.nalssilog.report.api.dto.AdminModerationActionPageResponse; +import com.nalssilog.report.api.dto.AdminProcessFlagRequest; +import com.nalssilog.report.api.dto.AdminReportFlagPageResponse; +import com.nalssilog.report.api.dto.AdminReportModerationResponse; +import com.nalssilog.report.api.dto.AdminRestrictAuthorRequest; +import com.nalssilog.report.application.AdminModerationService; +import com.nalssilog.report.domain.ReportFlagStatus; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.PostMapping; +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; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminReportModerationController { + + private final AdminModerationService moderationService; + + @GetMapping("/report-flags") + public AdminReportFlagPageResponse flags( + @RequestParam(required = false) ReportFlagStatus status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return moderationService.listFlags(status, page, size); + } + + @PatchMapping("/report-flags/{flagId}") + public AdminReportFlagPageResponse.Item processFlag( + @PathVariable Long flagId, + @AuthenticationPrincipal Long adminMemberId, + @Valid @RequestBody AdminProcessFlagRequest request + ) { + return moderationService.processFlag(flagId, adminMemberId, request); + } + + @PostMapping("/reports/{reportId}/moderation") + public AdminReportModerationResponse moderate( + @PathVariable Long reportId, + @AuthenticationPrincipal Long adminMemberId, + @Valid @RequestBody AdminModerateReportRequest request + ) { + return moderationService.moderate(reportId, adminMemberId, request); + } + + @PostMapping("/reports/{reportId}/author-restriction") + public AdminActorRestrictionResponse restrictAuthor( + @PathVariable Long reportId, + @AuthenticationPrincipal Long adminMemberId, + @Valid @RequestBody AdminRestrictAuthorRequest request + ) { + return moderationService.restrictAuthor(reportId, adminMemberId, request); + } + + @DeleteMapping("/reports/{reportId}/author-restriction") + public AdminActorRestrictionResponse liftRestriction( + @PathVariable Long reportId, + @AuthenticationPrincipal Long adminMemberId + ) { + return moderationService.liftAuthorRestriction(reportId, adminMemberId); + } + + @GetMapping("/moderation-actions") + public AdminModerationActionPageResponse actions( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return moderationService.listActions(page, size); + } +} diff --git a/report/src/main/java/com/nalssilog/report/api/ReportBlockController.java b/report/src/main/java/com/nalssilog/report/api/ReportBlockController.java new file mode 100644 index 0000000..6e859ff --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/ReportBlockController.java @@ -0,0 +1,56 @@ +package com.nalssilog.report.api; + +import com.nalssilog.report.api.dto.AuthorBlockResponse; +import com.nalssilog.report.api.dto.BlockedAuthorPageResponse; +import com.nalssilog.report.application.ActorBlockService; +import com.nalssilog.report.application.dto.ReportActor; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/report-blocks") +@RequiredArgsConstructor +public class ReportBlockController { + + private final ActorBlockService blockService; + + @PostMapping("/members/{memberId}") + public AuthorBlockResponse blockMember( + @PathVariable Long memberId, + @AuthenticationPrincipal Long blockerMemberId + ) { + return blockService.blockMember(memberId, ReportActor.member(blockerMemberId)); + } + + @DeleteMapping("/members/{memberId}") + public AuthorBlockResponse unblockMember( + @PathVariable Long memberId, + @AuthenticationPrincipal Long blockerMemberId + ) { + return blockService.unblockMember(memberId, ReportActor.member(blockerMemberId)); + } + + @GetMapping + public BlockedAuthorPageResponse list( + @AuthenticationPrincipal Long memberId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return blockService.list(ReportActor.member(memberId), page, size); + } + + @DeleteMapping("/{blockId}") + public AuthorBlockResponse unblock( + @PathVariable Long blockId, + @AuthenticationPrincipal Long memberId + ) { + return blockService.unblock(blockId, ReportActor.member(memberId)); + } +} diff --git a/report/src/main/java/com/nalssilog/report/api/ReportController.java b/report/src/main/java/com/nalssilog/report/api/ReportController.java index 919c0f4..51ed2c0 100644 --- a/report/src/main/java/com/nalssilog/report/api/ReportController.java +++ b/report/src/main/java/com/nalssilog/report/api/ReportController.java @@ -2,12 +2,18 @@ import com.nalssilog.common.response.CursorPage; import com.nalssilog.report.api.dto.CreateReportRequest; +import com.nalssilog.report.api.dto.CreateReportFlagRequest; +import com.nalssilog.report.api.dto.ReportFlagResponse; +import com.nalssilog.report.application.ActorRestrictionService; +import com.nalssilog.report.application.ReportFlagService; +import com.nalssilog.report.application.ReportRateLimiter; import com.nalssilog.report.api.dto.ReportResponse; import com.nalssilog.report.api.dto.ThanksResponse; import com.nalssilog.report.api.dto.WeatherStatsResponse; import com.nalssilog.report.application.ReportService; import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.config.ReportActorResolver; +import com.nalssilog.report.config.ReportClientIpResolver; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -31,6 +37,10 @@ public class ReportController { private final ReportService reportService; private final ReportActorResolver actorResolver; + private final ReportClientIpResolver clientIpResolver; + private final ReportRateLimiter rateLimiter; + private final ActorRestrictionService restrictionService; + private final ReportFlagService flagService; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -42,9 +52,28 @@ public ReportResponse create( ) { ReportActor actor = actorResolver.resolveForWrite(memberId, httpRequest, httpResponse); + restrictionService.ensureCanPost(actor); + rateLimiter.checkCreate(actor, clientIpResolver.resolve(httpRequest)); + return reportService.create(actor, request.toCommand()); } + @PostMapping("/{id}/flags") + @ResponseStatus(HttpStatus.CREATED) + public ReportFlagResponse flag( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody CreateReportFlagRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + ReportActor actor = actorResolver.resolveForWrite(memberId, httpRequest, httpResponse); + + rateLimiter.checkFlag(actor, clientIpResolver.resolve(httpRequest)); + + return flagService.flag(id, actor, request); + } + @GetMapping public CursorPage list( @RequestParam Long locationId, diff --git a/report/src/main/java/com/nalssilog/report/api/ReportImageController.java b/report/src/main/java/com/nalssilog/report/api/ReportImageController.java index b26507a..00b9bbf 100644 --- a/report/src/main/java/com/nalssilog/report/api/ReportImageController.java +++ b/report/src/main/java/com/nalssilog/report/api/ReportImageController.java @@ -3,7 +3,14 @@ import com.nalssilog.report.api.dto.PresignRequest; import com.nalssilog.report.api.dto.PresignResponse; import com.nalssilog.report.application.ReportImageService; +import com.nalssilog.report.application.ActorRestrictionService; +import com.nalssilog.report.application.ReportRateLimiter; import com.nalssilog.report.application.dto.ImageUploadSpec; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.config.ReportActorResolver; +import com.nalssilog.report.config.ReportClientIpResolver; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -11,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; /** * 제보 이미지 presigned 업로드 발급. 회원·익명 모두 제보에 사진을 붙일 수 있어 permitAll(POST /api/reports/**). @@ -22,9 +30,23 @@ public class ReportImageController { private final ReportImageService reportImageService; + private final ReportActorResolver actorResolver; + private final ReportClientIpResolver clientIpResolver; + private final ReportRateLimiter rateLimiter; + private final ActorRestrictionService restrictionService; @PostMapping("/presign") - public PresignResponse presign(@Valid @RequestBody PresignRequest request) { + public PresignResponse presign( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody PresignRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + ReportActor actor = actorResolver.resolveForWrite(memberId, httpRequest, httpResponse); + + restrictionService.ensureCanPost(actor); + rateLimiter.checkPresign(actor, clientIpResolver.resolve(httpRequest)); + List specs = request.images().stream() .map(image -> new ImageUploadSpec(image.contentType(), image.size())) .toList(); diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminActorRestrictionResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminActorRestrictionResponse.java new file mode 100644 index 0000000..600f7ae --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminActorRestrictionResponse.java @@ -0,0 +1,30 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ActorRestriction; +import com.nalssilog.report.domain.ActorType; +import java.time.Instant; + +public record AdminActorRestrictionResponse( + String id, + String sourceReportId, + ActorType actorType, + String reason, + Instant expiresAt, + Instant liftedAt, + boolean active +) { + + public static AdminActorRestrictionResponse from(ActorRestriction restriction) { + return new AdminActorRestrictionResponse( + String.valueOf(restriction.getId()), + restriction.getSourceReportId() == null + ? null + : String.valueOf(restriction.getSourceReportId()), + restriction.getActorType(), + restriction.getReason(), + restriction.getExpiresAt(), + restriction.getLiftedAt(), + restriction.getLiftedAt() == null + && (restriction.getExpiresAt() == null || restriction.getExpiresAt().isAfter(Instant.now()))); + } +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminModerateReportRequest.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminModerateReportRequest.java new file mode 100644 index 0000000..abf7e37 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminModerateReportRequest.java @@ -0,0 +1,16 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ReportModerationCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminModerateReportRequest( + @NotNull(message = "제보 조치가 필요합니다.") + ReportModerationCommand action, + + @NotBlank(message = "운영 조치 사유가 필요합니다.") + @Size(max = 500, message = "운영 조치 사유는 500자 이하여야 합니다.") + String reason +) { +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminModerationActionPageResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminModerationActionPageResponse.java new file mode 100644 index 0000000..7f0a12f --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminModerationActionPageResponse.java @@ -0,0 +1,26 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.ModerationActionType; +import java.time.Instant; +import java.util.List; + +public record AdminModerationActionPageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + + public record Item( + String id, + ModerationActionType action, + String adminMemberId, + String reportId, + ActorType targetActorType, + String reason, + Instant createdAt + ) { + } +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminProcessFlagRequest.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminProcessFlagRequest.java new file mode 100644 index 0000000..559fa75 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminProcessFlagRequest.java @@ -0,0 +1,14 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ReportFlagStatus; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminProcessFlagRequest( + @NotNull(message = "신고 처리 결과가 필요합니다.") + ReportFlagStatus status, + + @Size(max = 500, message = "처리 메모는 500자 이하여야 합니다.") + String note +) { +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminReportFlagPageResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminReportFlagPageResponse.java new file mode 100644 index 0000000..9b30e8c --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminReportFlagPageResponse.java @@ -0,0 +1,43 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.ModerationStatus; +import com.nalssilog.report.domain.ReportFlagReason; +import com.nalssilog.report.domain.ReportFlagStatus; +import java.time.Instant; +import java.util.List; + +public record AdminReportFlagPageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + + public record Item( + String id, + String reportId, + ReportFlagReason reason, + String detail, + ReportFlagStatus status, + ActorType reporterType, + Instant createdAt, + Instant processedAt, + String processedByMemberId, + String resolutionNote, + Report report + ) { + } + + public record Report( + String locationId, + ActorType authorType, + String authorMemberId, + String comment, + List imageUrls, + ModerationStatus moderationStatus, + Instant createdAt + ) { + } +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminReportModerationResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminReportModerationResponse.java new file mode 100644 index 0000000..3e18ce9 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminReportModerationResponse.java @@ -0,0 +1,6 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ModerationStatus; + +public record AdminReportModerationResponse(String reportId, ModerationStatus status) { +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AdminRestrictAuthorRequest.java b/report/src/main/java/com/nalssilog/report/api/dto/AdminRestrictAuthorRequest.java new file mode 100644 index 0000000..f4bbd23 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AdminRestrictAuthorRequest.java @@ -0,0 +1,15 @@ +package com.nalssilog.report.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.Instant; + +public record AdminRestrictAuthorRequest( + @NotBlank(message = "작성 제한 사유가 필요합니다.") + @Size(max = 500, message = "작성 제한 사유는 500자 이하여야 합니다.") + String reason, + + /** null이면 무기한, 값이 있으면 해당 시각까지 제한. */ + Instant expiresAt +) { +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/AuthorBlockResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/AuthorBlockResponse.java new file mode 100644 index 0000000..b5e5f7a --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/AuthorBlockResponse.java @@ -0,0 +1,4 @@ +package com.nalssilog.report.api.dto; + +public record AuthorBlockResponse(String blockId, boolean blocked) { +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/BlockedAuthorPageResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/BlockedAuthorPageResponse.java new file mode 100644 index 0000000..55e061d --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/BlockedAuthorPageResponse.java @@ -0,0 +1,29 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.report.domain.ActorType; +import java.time.Instant; +import java.util.List; + +public record BlockedAuthorPageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + + public record BlockedAuthor( + String blockId, + ActorType type, + String memberId, + String nickname, + Avatar avatar, + Instant blockedAt + ) { + } + + /** PRESET은 프리셋 ID, CUSTOM은 공개 이미지 URL, DEFAULT는 value=null. */ + public record Avatar(AvatarType type, String value) { + } +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/CreateReportFlagRequest.java b/report/src/main/java/com/nalssilog/report/api/dto/CreateReportFlagRequest.java new file mode 100644 index 0000000..0727355 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/CreateReportFlagRequest.java @@ -0,0 +1,14 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ReportFlagReason; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CreateReportFlagRequest( + @NotNull(message = "신고 사유가 필요합니다.") + ReportFlagReason reason, + + @Size(max = 500, message = "신고 상세 내용은 500자 이하여야 합니다.") + String detail +) { +} diff --git a/report/src/main/java/com/nalssilog/report/api/dto/CreateReportRequest.java b/report/src/main/java/com/nalssilog/report/api/dto/CreateReportRequest.java index df2d1c3..ebd1cb5 100644 --- a/report/src/main/java/com/nalssilog/report/api/dto/CreateReportRequest.java +++ b/report/src/main/java/com/nalssilog/report/api/dto/CreateReportRequest.java @@ -1,9 +1,11 @@ package com.nalssilog.report.api.dto; +import com.nalssilog.member.application.dto.TermsAgreement; import com.nalssilog.report.application.dto.CreateReportCommand; import com.nalssilog.report.domain.Precipitation; import com.nalssilog.report.domain.Sunlight; import com.nalssilog.report.domain.Temperature; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -13,6 +15,7 @@ * imageKeys 는 presigned 업로드 후 받은 storage_key 목록(최대 3장). * 체감 3축: temperature(COLD/FRESH/HOT), precipitation(NONE/LIGHT/HEAVY), sunlight(LOW/MODERATE/STRONG). * 제품 정책: comment 필수(1~100자), 이미지 최대 3장. + * 비회원 제보는 agreedTerms 에 SERVICE, PRIVACY 동의를 각각 문서 버전과 함께 전달해야 한다. */ public record CreateReportRequest( @NotNull(message = "지역을 선택해 주세요.") @@ -31,11 +34,14 @@ public record CreateReportRequest( @Size(min = 1, max = 100, message = "한마디는 1~100자여야 합니다.") String comment, - List<@NotBlank String> imageKeys + List<@NotBlank String> imageKeys, + + List<@Valid @NotNull(message = "약관 동의 항목이 비어 있습니다.") TermsAgreement> agreedTerms ) { public CreateReportCommand toCommand() { return new CreateReportCommand(locationId, temperature, precipitation, sunlight, comment, - imageKeys == null ? List.of() : imageKeys); + imageKeys == null ? List.of() : List.copyOf(imageKeys), + agreedTerms == null ? List.of() : List.copyOf(agreedTerms)); } } diff --git a/report/src/main/java/com/nalssilog/report/api/dto/ReportFlagResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/ReportFlagResponse.java new file mode 100644 index 0000000..a2593ab --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/ReportFlagResponse.java @@ -0,0 +1,24 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.domain.ReportFlag; +import com.nalssilog.report.domain.ReportFlagReason; +import com.nalssilog.report.domain.ReportFlagStatus; +import java.time.Instant; + +public record ReportFlagResponse( + String id, + String reportId, + ReportFlagReason reason, + ReportFlagStatus status, + Instant createdAt +) { + + public static ReportFlagResponse from(ReportFlag flag) { + return new ReportFlagResponse( + String.valueOf(flag.getId()), + String.valueOf(flag.getReport().getId()), + flag.getReason(), + flag.getStatus(), + flag.getCreatedAt()); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ActorBlockService.java b/report/src/main/java/com/nalssilog/report/application/ActorBlockService.java new file mode 100644 index 0000000..8b9bae3 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/ActorBlockService.java @@ -0,0 +1,160 @@ +package com.nalssilog.report.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.MemberVisibilityPolicy; +import com.nalssilog.report.api.dto.AuthorBlockResponse; +import com.nalssilog.report.api.dto.BlockedAuthorPageResponse; +import com.nalssilog.report.api.dto.BlockedAuthorPageResponse.Avatar; +import com.nalssilog.report.api.dto.BlockedAuthorPageResponse.BlockedAuthor; +import com.nalssilog.report.application.dto.AuthorInfo; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.client.MemberClient; +import com.nalssilog.report.domain.ActorBlock; +import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.repository.ActorBlockJpaRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActorBlockService implements MemberVisibilityPolicy { + + private static final int MAX_BLOCKS = 200; + private static final int MAX_PAGE_SIZE = 50; + + private final ActorBlockJpaRepository blockRepository; + private final MemberClient memberClient; + + @Transactional + public AuthorBlockResponse blockMember(Long memberId, ReportActor blocker) { + ensureMemberBlocker(blocker); + + if (memberClient.findActiveAuthor(memberId).isEmpty()) { + throw new NalssiLogException(ReportErrorCode.BLOCK_TARGET_MEMBER_NOT_FOUND); + } + + return block(blocker, ReportActor.member(memberId)); + } + + private AuthorBlockResponse block( + ReportActor blocker, + ReportActor blocked + ) { + ensureMemberBlocker(blocker); + + if (sameActor(blocker, blocked)) { + throw new NalssiLogException(ReportErrorCode.CANNOT_BLOCK_SELF); + } + + Optional existing = find(blocker, blocked); + + if (existing.isPresent()) { + return response(existing.get(), true); + } + + if (blockRepository.countByBlockerTypeAndBlockerKey(blocker.type(), blocker.actorKey()) >= MAX_BLOCKS) { + throw new NalssiLogException(ReportErrorCode.BLOCK_LIMIT_REACHED); + } + + try { + return response(blockRepository.saveAndFlush( + ActorBlock.create(blocker, blocked)), true); + } catch (DataIntegrityViolationException exception) { + return find(blocker, blocked) + .map(block -> response(block, true)) + .orElseThrow(() -> exception); + } + } + + @Transactional + public AuthorBlockResponse unblockMember(Long memberId, ReportActor blocker) { + ensureMemberBlocker(blocker); + Optional block = find(blocker, ReportActor.member(memberId)); + + block.ifPresent(blockRepository::delete); + + return new AuthorBlockResponse( + block.map(value -> String.valueOf(value.getId())).orElse(null), false); + } + + @Transactional + public AuthorBlockResponse unblock(Long blockId, ReportActor blocker) { + ensureMemberBlocker(blocker); + Optional found = blockRepository.findById(blockId) + .filter(block -> block.getBlockerType() == blocker.type()) + .filter(block -> block.getBlockerKey().equals(blocker.actorKey())); + + found.ifPresent(blockRepository::delete); + + return new AuthorBlockResponse(found.map(block -> String.valueOf(block.getId())).orElse(null), false); + } + + public BlockedAuthorPageResponse list(ReportActor blocker, int page, int size) { + ensureMemberBlocker(blocker); + int safePage = Math.max(page, 0); + int safeSize = Math.max(1, Math.min(size, MAX_PAGE_SIZE)); + Page blocks = blockRepository.findAllByBlockerTypeAndBlockerKeyOrderByCreatedAtDesc( + blocker.type(), blocker.actorKey(), PageRequest.of(safePage, safeSize)); + + return new BlockedAuthorPageResponse( + blocks.getContent().stream().map(this::toBlockedAuthor).toList(), + blocks.getNumber(), + blocks.getSize(), + blocks.getTotalElements(), + blocks.getTotalPages()); + } + + @Override + public boolean canView(Long viewerMemberId, Long targetMemberId) { + if (viewerMemberId == null + || targetMemberId == null + || viewerMemberId.equals(targetMemberId)) { + return true; + } + + ReportActor viewer = ReportActor.member(viewerMemberId); + ReportActor target = ReportActor.member(targetMemberId); + + return find(viewer, target).isEmpty() + && find(target, viewer).isEmpty(); + } + + private BlockedAuthor toBlockedAuthor(ActorBlock block) { + Long memberId = Long.valueOf(block.getBlockedKey()); + AuthorInfo author = memberClient.findActiveAuthor(memberId).orElse(null); + + return new BlockedAuthor( + String.valueOf(block.getId()), + ActorType.MEMBER, + String.valueOf(memberId), + author == null ? "탈퇴한 이웃" : author.nickname(), + author == null ? null : new Avatar(author.avatarType(), author.avatarValue()), + block.getCreatedAt()); + } + + private Optional find(ReportActor blocker, ReportActor blocked) { + return blockRepository.findByBlockerTypeAndBlockerKeyAndBlockedTypeAndBlockedKey( + blocker.type(), blocker.actorKey(), blocked.type(), blocked.actorKey()); + } + + private void ensureMemberBlocker(ReportActor blocker) { + if (blocker == null || blocker.type() != ActorType.MEMBER || blocker.memberId() == null) { + throw new NalssiLogException(ReportErrorCode.BLOCK_MEMBER_REQUIRED); + } + } + + private boolean sameActor(ReportActor first, ReportActor second) { + return first.type() == second.type() && first.actorKey().equals(second.actorKey()); + } + + private AuthorBlockResponse response(ActorBlock block, boolean blocked) { + return new AuthorBlockResponse(String.valueOf(block.getId()), blocked); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ActorRestrictionService.java b/report/src/main/java/com/nalssilog/report/application/ActorRestrictionService.java new file mode 100644 index 0000000..b2eb29c --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/ActorRestrictionService.java @@ -0,0 +1,32 @@ +package com.nalssilog.report.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.domain.ActorRestriction; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.repository.ActorRestrictionJpaRepository; +import java.time.Instant; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActorRestrictionService { + + private final ActorRestrictionJpaRepository restrictionRepository; + + public void ensureCanPost(ReportActor actor) { + if (findActive(actor).isPresent()) { + throw new NalssiLogException(ReportErrorCode.ACTOR_POSTING_RESTRICTED); + } + } + + public Optional findActive(ReportActor actor) { + return restrictionRepository.findActive(actor.type(), actor.actorKey(), Instant.now()) + .stream() + .findFirst(); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/AdminModerationService.java b/report/src/main/java/com/nalssilog/report/application/AdminModerationService.java new file mode 100644 index 0000000..3a05207 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/AdminModerationService.java @@ -0,0 +1,248 @@ +package com.nalssilog.report.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.api.dto.AdminActorRestrictionResponse; +import com.nalssilog.report.api.dto.AdminModerateReportRequest; +import com.nalssilog.report.api.dto.AdminModerationActionPageResponse; +import com.nalssilog.report.api.dto.AdminProcessFlagRequest; +import com.nalssilog.report.api.dto.AdminReportFlagPageResponse; +import com.nalssilog.report.api.dto.AdminReportModerationResponse; +import com.nalssilog.report.api.dto.AdminRestrictAuthorRequest; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.client.ImageStorageClient; +import com.nalssilog.report.domain.ActorRestriction; +import com.nalssilog.report.domain.ModerationAction; +import com.nalssilog.report.domain.ModerationActionType; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.domain.ReportFlag; +import com.nalssilog.report.domain.ReportFlagStatus; +import com.nalssilog.report.domain.WeatherReport; +import com.nalssilog.report.domain.WeatherReportImage; +import com.nalssilog.report.repository.ActorRestrictionJpaRepository; +import com.nalssilog.report.repository.ModerationActionJpaRepository; +import com.nalssilog.report.repository.ReportFlagJpaRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminModerationService { + + private static final int MAX_PAGE_SIZE = 100; + + private final WeatherReportRepository reportRepository; + private final ReportFlagJpaRepository flagRepository; + private final ActorRestrictionJpaRepository restrictionRepository; + private final ModerationActionJpaRepository actionRepository; + private final ActorRestrictionService restrictionService; + private final ImageStorageClient imageStorageClient; + + public AdminReportFlagPageResponse listFlags( + ReportFlagStatus status, + int page, + int size + ) { + PageRequest pageable = pageRequest(page, size); + Page flags = status == null + ? flagRepository.findAllByOrderByCreatedAtAsc(pageable) + : flagRepository.findAllByStatusOrderByCreatedAtAsc(status, pageable); + + return new AdminReportFlagPageResponse( + flags.getContent().stream().map(this::flagItem).toList(), + flags.getNumber(), flags.getSize(), flags.getTotalElements(), flags.getTotalPages()); + } + + @Transactional + public AdminReportFlagPageResponse.Item processFlag( + Long flagId, + Long adminMemberId, + AdminProcessFlagRequest request + ) { + ReportFlag flag = flagRepository.findById(flagId) + .orElseThrow(() -> new NalssiLogException(ReportErrorCode.REPORT_FLAG_NOT_FOUND)); + + flag.process(request.status(), adminMemberId, normalize(request.note())); + actionRepository.save(ModerationAction.create( + request.status() == ReportFlagStatus.RESOLVED + ? ModerationActionType.FLAG_RESOLVED + : ModerationActionType.FLAG_REJECTED, + adminMemberId, + flag.getReport().getId(), + ReportActor.authorOf(flag.getReport()), + normalizeOrDefault(request.note(), "신고 처리"))); + + return flagItem(flag); + } + + @Transactional + public AdminReportModerationResponse moderate( + Long reportId, + Long adminMemberId, + AdminModerateReportRequest request + ) { + WeatherReport report = reportRepository.getReportEntity(reportId); + ModerationActionType actionType = switch (request.action()) { + case HIDE -> { + report.hide(); + yield ModerationActionType.REPORT_HIDDEN; + } + case RESTORE -> { + report.restore(); + yield ModerationActionType.REPORT_RESTORED; + } + case REMOVE -> { + report.removeByModerator(); + yield ModerationActionType.REPORT_REMOVED; + } + }; + + if (request.action() != com.nalssilog.report.domain.ReportModerationCommand.RESTORE) { + flagRepository.findAllByReport_IdAndStatus(reportId, ReportFlagStatus.PENDING) + .forEach(flag -> flag.process( + ReportFlagStatus.RESOLVED, + adminMemberId, + "콘텐츠 운영 조치: " + request.reason().strip())); + } + + actionRepository.save(ModerationAction.create( + actionType, + adminMemberId, + reportId, + ReportActor.authorOf(report), + request.reason().strip())); + + return new AdminReportModerationResponse( + String.valueOf(reportId), report.getModerationStatus()); + } + + @Transactional + public AdminActorRestrictionResponse restrictAuthor( + Long reportId, + Long adminMemberId, + AdminRestrictAuthorRequest request + ) { + if (request.expiresAt() != null && !request.expiresAt().isAfter(Instant.now())) { + throw new NalssiLogException(ReportErrorCode.INVALID_RESTRICTION_EXPIRY); + } + + WeatherReport report = reportRepository.getReportEntity(reportId); + ReportActor author = ReportActor.authorOf(report); + + if (restrictionService.findActive(author).isPresent()) { + throw new NalssiLogException(ReportErrorCode.AUTHOR_ALREADY_RESTRICTED); + } + + ActorRestriction restriction = restrictionRepository.save(ActorRestriction.create( + author, + reportId, + request.reason().strip(), + request.expiresAt(), + adminMemberId)); + + actionRepository.save(ModerationAction.create( + ModerationActionType.AUTHOR_RESTRICTED, + adminMemberId, + reportId, + author, + request.reason().strip())); + + return AdminActorRestrictionResponse.from(restriction); + } + + @Transactional + public AdminActorRestrictionResponse liftAuthorRestriction( + Long reportId, + Long adminMemberId + ) { + WeatherReport report = reportRepository.getReportEntity(reportId); + ReportActor author = ReportActor.authorOf(report); + ActorRestriction restriction = restrictionService.findActive(author) + .orElseThrow(() -> new NalssiLogException( + ReportErrorCode.AUTHOR_RESTRICTION_NOT_FOUND)); + + restriction.lift(adminMemberId); + actionRepository.save(ModerationAction.create( + ModerationActionType.AUTHOR_RESTRICTION_LIFTED, + adminMemberId, + reportId, + author, + "관리자 작성 제한 해제")); + + return AdminActorRestrictionResponse.from(restriction); + } + + public AdminModerationActionPageResponse listActions(int page, int size) { + Page actions = actionRepository.findAllByOrderByCreatedAtDesc( + pageRequest(page, size)); + + List items = actions.getContent().stream() + .map(action -> new AdminModerationActionPageResponse.Item( + String.valueOf(action.getId()), + action.getActionType(), + String.valueOf(action.getAdminMemberId()), + action.getReportId() == null ? null : String.valueOf(action.getReportId()), + action.getTargetActorType(), + action.getReason(), + action.getCreatedAt())) + .toList(); + + return new AdminModerationActionPageResponse( + items, + actions.getNumber(), actions.getSize(), + actions.getTotalElements(), actions.getTotalPages()); + } + + private AdminReportFlagPageResponse.Item flagItem(ReportFlag flag) { + WeatherReport report = flag.getReport(); + List imageUrls = report.getImages().stream() + .sorted(Comparator.comparingInt(WeatherReportImage::getDisplayOrder)) + .map(WeatherReportImage::getStorageKey) + .map(imageStorageClient::toPublicUrl) + .toList(); + AdminReportFlagPageResponse.Report reportItem = new AdminReportFlagPageResponse.Report( + String.valueOf(report.getLocationId()), + report.getAuthorType(), + report.getAuthorMemberId() == null ? null : String.valueOf(report.getAuthorMemberId()), + report.getComment(), + imageUrls, + report.getModerationStatus(), + report.getCreatedAt()); + + return new AdminReportFlagPageResponse.Item( + String.valueOf(flag.getId()), + String.valueOf(report.getId()), + flag.getReason(), + flag.getDetail(), + flag.getStatus(), + flag.getReporterType(), + flag.getCreatedAt(), + flag.getProcessedAt(), + flag.getProcessedByMemberId() == null + ? null + : String.valueOf(flag.getProcessedByMemberId()), + flag.getResolutionNote(), + reportItem); + } + + private PageRequest pageRequest(int page, int size) { + return PageRequest.of(Math.max(page, 0), Math.max(1, Math.min(size, MAX_PAGE_SIZE))); + } + + private String normalize(String value) { + return value == null || value.isBlank() ? null : value.strip(); + } + + private String normalizeOrDefault(String value, String fallback) { + String normalized = normalize(value); + + return normalized == null ? fallback : normalized; + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportConsentPolicy.java b/report/src/main/java/com/nalssilog/report/application/ReportConsentPolicy.java new file mode 100644 index 0000000..834c212 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/ReportConsentPolicy.java @@ -0,0 +1,73 @@ +package com.nalssilog.report.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.TermsType; +import com.nalssilog.report.domain.ReportErrorCode; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * 비회원 제보에 필요한 필수 약관 동의를 검증하고 저장 가능한 형태로 정규화한다. + */ +@Component +public class ReportConsentPolicy { + + private static final int MAX_VERSION_LENGTH = 20; + private static final List REQUIRED_TYPES = List.of( + TermsType.SERVICE, + TermsType.PRIVACY + ); + + public List validate(List agreedTerms) { + if (agreedTerms == null) { + throw termsNotAgreed(); + } + + Map versions = new EnumMap<>(TermsType.class); + + for (TermsAgreement agreement : agreedTerms) { + if (agreement == null || agreement.type() == null || !REQUIRED_TYPES.contains(agreement.type())) { + throw termsNotAgreed(); + } + + String version = normalizeVersion(agreement.version()); + String existing = versions.putIfAbsent(agreement.type(), version); + + if (existing != null) { + throw termsNotAgreed(); + } + } + + if (!versions.keySet().containsAll(REQUIRED_TYPES)) { + throw termsNotAgreed(); + } + + List normalized = new ArrayList<>(REQUIRED_TYPES.size()); + + REQUIRED_TYPES.forEach(type -> normalized.add(new TermsAgreement(type, versions.get(type)))); + + return List.copyOf(normalized); + } + + private String normalizeVersion(String version) { + if (version == null) { + throw termsNotAgreed(); + } + + String normalized = version.strip(); + + if (normalized.isEmpty() || normalized.length() > MAX_VERSION_LENGTH) { + throw termsNotAgreed(); + } + + return normalized; + } + + private NalssiLogException termsNotAgreed() { + return new NalssiLogException(ReportErrorCode.TERMS_NOT_AGREED); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportFlagService.java b/report/src/main/java/com/nalssilog/report/application/ReportFlagService.java new file mode 100644 index 0000000..b2a02b5 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/ReportFlagService.java @@ -0,0 +1,64 @@ +package com.nalssilog.report.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.api.dto.CreateReportFlagRequest; +import com.nalssilog.report.api.dto.ReportFlagResponse; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.domain.ReportFlag; +import com.nalssilog.report.domain.WeatherReport; +import com.nalssilog.report.repository.ReportFlagJpaRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportFlagService { + + private final WeatherReportRepository reportRepository; + private final ReportFlagJpaRepository flagRepository; + + @Transactional + public ReportFlagResponse flag( + Long reportId, + ReportActor reporter, + CreateReportFlagRequest request + ) { + WeatherReport report = reportRepository.getVisibleReportEntity(reportId); + ReportActor author = ReportActor.authorOf(report); + + if (sameActor(author, reporter)) { + throw new NalssiLogException(ReportErrorCode.CANNOT_FLAG_OWN_REPORT); + } + + if (flagRepository.existsByReport_IdAndReporterTypeAndReporterKey( + reportId, reporter.type(), reporter.actorKey())) { + throw new NalssiLogException(ReportErrorCode.REPORT_ALREADY_FLAGGED); + } + + try { + ReportFlag saved = flagRepository.saveAndFlush(ReportFlag.create( + report, reporter, request.reason(), normalize(request.detail()))); + + return ReportFlagResponse.from(saved); + } catch (DataIntegrityViolationException exception) { + throw new NalssiLogException(ReportErrorCode.REPORT_ALREADY_FLAGGED); + } + } + + private boolean sameActor(ReportActor first, ReportActor second) { + return first.type() == second.type() && first.actorKey().equals(second.actorKey()); + } + + private String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + + return value.strip(); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportRateLimiter.java b/report/src/main/java/com/nalssilog/report/application/ReportRateLimiter.java new file mode 100644 index 0000000..80eb3c2 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/ReportRateLimiter.java @@ -0,0 +1,165 @@ +package com.nalssilog.report.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import com.nalssilog.member.config.FeedbackRateLimitProperties; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.config.ReportRateLimitProperties; +import com.nalssilog.report.domain.ReportErrorCode; +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ReportRateLimiter { + + private static final String KEY_PREFIX = "report:rate:"; + private static final DefaultRedisScript CHECK_SCRIPT = new DefaultRedisScript<>(""" + local actorCount = redis.call('INCR', KEYS[1]) + if actorCount == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + end + + local ipCount = redis.call('INCR', KEYS[2]) + if ipCount == 1 then + redis.call('PEXPIRE', KEYS[2], ARGV[1]) + end + + if tonumber(ARGV[4]) > 0 then + local dailyCount = redis.call('INCR', KEYS[3]) + if dailyCount == 1 then + redis.call('PEXPIRE', KEYS[3], ARGV[3]) + end + local ipDailyCount = redis.call('INCR', KEYS[4]) + if ipDailyCount == 1 then + redis.call('PEXPIRE', KEYS[4], ARGV[3]) + end + if dailyCount > tonumber(ARGV[4]) then + return 3 + end + if ipDailyCount > tonumber(ARGV[4]) * tonumber(ARGV[5]) * tonumber(ARGV[5]) then + return 4 + end + end + + if actorCount > tonumber(ARGV[2]) then + return 1 + end + if ipCount > tonumber(ARGV[2]) * tonumber(ARGV[5]) then + return 2 + end + return 0 + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final ReportRateLimitProperties properties; + private final String hmacSecret; + + public ReportRateLimiter( + StringRedisTemplate redisTemplate, + ReportRateLimitProperties properties, + FeedbackRateLimitProperties fallbackProperties + ) { + this.redisTemplate = redisTemplate; + this.properties = properties; + String baseSecret = properties.hmacSecret() == null + ? fallbackProperties.ipHmacSecret() + : properties.hmacSecret(); + + // 기존 서버 비밀값을 그대로 재사용하지 않고 용도 문자열로 한 번 파생해 fingerprint 간 상관관계를 끊는다. + this.hmacSecret = SecretFingerprint.hmacSha256( + baseSecret, "nalssilog:report-rate-limit:v1"); + } + + public void checkCreate(ReportActor actor, String clientIp) { + check(Action.CREATE, actor, clientIp, + properties.createMaxRequests(), properties.createWindow(), + properties.createDailyMaxRequests()); + } + + public void checkPresign(ReportActor actor, String clientIp) { + check(Action.PRESIGN, actor, clientIp, + properties.presignMaxRequests(), properties.presignWindow(), + properties.presignDailyMaxRequests()); + } + + public void checkFlag(ReportActor actor, String clientIp) { + check(Action.FLAG, actor, clientIp, + properties.flagMaxRequests(), properties.flagWindow(), + properties.flagDailyMaxRequests()); + } + + private void check( + Action action, + ReportActor actor, + String clientIp, + int maxRequests, + Duration window, + int dailyMaxRequests + ) { + String actorFingerprint = SecretFingerprint.hmacSha256( + hmacSecret, actor.type() + ":" + actor.actorKey()); + String ipFingerprint = SecretFingerprint.hmacSha256( + hmacSecret, clientIp == null || clientIp.isBlank() ? "unknown" : clientIp.strip()); + String prefix = KEY_PREFIX + action.key + ":"; + Long result; + + try { + result = redisTemplate.execute( + CHECK_SCRIPT, + List.of( + prefix + "actor:" + actorFingerprint, + prefix + "ip:" + ipFingerprint, + prefix + "daily:" + actorFingerprint, + prefix + "ip-daily:" + ipFingerprint), + String.valueOf(window.toMillis()), + String.valueOf(maxRequests), + String.valueOf(Duration.ofDays(1).toMillis()), + String.valueOf(dailyMaxRequests), + String.valueOf(properties.ipMultiplier())); + } catch (DataAccessException exception) { + log.warn("report.rate_limit_unavailable action={} actor={} reason={}", + action.key, actorFingerprint.substring(0, 12), exception.getClass().getSimpleName()); + throw new NalssiLogException(ReportErrorCode.RATE_LIMIT_UNAVAILABLE); + } + + if (result == null) { + throw new NalssiLogException(ReportErrorCode.RATE_LIMIT_UNAVAILABLE); + } + + if (result != 0) { + log.warn("report.rate_limited action={} scope={} actor={}", + action.key, scope(result), actorFingerprint.substring(0, 12)); + throw new NalssiLogException(action.errorCode); + } + } + + private String scope(long result) { + return switch ((int) result) { + case 1 -> "actor"; + case 2 -> "ip"; + case 3 -> "daily"; + case 4 -> "ip-daily"; + default -> "unknown"; + }; + } + + private enum Action { + CREATE("create", ReportErrorCode.REPORT_RATE_LIMITED), + PRESIGN("presign", ReportErrorCode.IMAGE_PRESIGN_RATE_LIMITED), + FLAG("flag", ReportErrorCode.REPORT_FLAG_RATE_LIMITED); + + private final String key; + private final ReportErrorCode errorCode; + + Action(String key, ReportErrorCode errorCode) { + this.key = key; + this.errorCode = errorCode; + } + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index a78b451..bfa39fe 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -2,6 +2,7 @@ import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.CursorPage; +import com.nalssilog.member.application.dto.TermsAgreement; import com.nalssilog.report.api.dto.ReportResponse; import com.nalssilog.report.api.dto.ThanksResponse; import com.nalssilog.report.api.dto.WeatherStatsResponse; @@ -46,9 +47,13 @@ public class ReportService { private final LocationClient locationClient; private final ImageStorageClient imageStorageClient; private final ApplicationEventPublisher eventPublisher; + private final ReportConsentPolicy reportConsentPolicy; @Transactional public ReportResponse create(ReportActor actor, CreateReportCommand command) { + List reportConsents = actor.type() == ActorType.ANONYMOUS + ? reportConsentPolicy.validate(command.agreedTerms()) + : List.of(); LocationSummary location = locationClient.getLocation(command.locationId()); imageStorageClient.validateImageCount(command.imageKeys().size()); @@ -62,6 +67,8 @@ public ReportResponse create(ReportActor actor, CreateReportCommand command) { command.temperature(), command.precipitation(), command.sunlight(), command.comment()); report.addImages(command.imageKeys()); + Instant agreedAt = Instant.now(); + reportConsents.forEach(consent -> report.addConsent(consent.type(), consent.version(), agreedAt)); ReportData data = reportRepository.save(report); return ReportResponse.of(data, location, resolveAuthor(data), 0L, false, true, resolveImageUrls(data)); @@ -73,7 +80,8 @@ public CursorPage list(Long locationId, String cursor, ReportAct Instant cursorTime = decoded == null ? null : decoded.createdAt(); Long cursorId = decoded == null ? null : decoded.id(); - List fetched = reportRepository.findPage(locationId, cursorTime, cursorId, PAGE_SIZE + 1); + List fetched = reportRepository.findPage( + locationId, cursorTime, cursorId, viewer, PAGE_SIZE + 1); boolean hasNext = fetched.size() > PAGE_SIZE; List page = hasNext ? fetched.subList(0, PAGE_SIZE) : fetched; @@ -116,7 +124,8 @@ public CursorPage listByMember(Long memberId, String cursor, Rep Instant cursorTime = decoded == null ? null : decoded.createdAt(); Long cursorId = decoded == null ? null : decoded.id(); - List fetched = reportRepository.findMemberPage(memberId, cursorTime, cursorId, PAGE_SIZE + 1); + List fetched = reportRepository.findMemberPage( + memberId, cursorTime, cursorId, viewer, PAGE_SIZE + 1); boolean hasNext = fetched.size() > PAGE_SIZE; List page = hasNext ? fetched.subList(0, PAGE_SIZE) : fetched; @@ -157,7 +166,7 @@ public WeatherStatsResponse stats(Long locationId) { } public ReportResponse get(Long reportId, ReportActor viewer, List ownershipActors) { - ReportData data = reportRepository.getReport(reportId); + ReportData data = reportRepository.getReport(reportId, viewer); LocationSummary location = locationClient.getLocation(data.locationId()); long thanksCount = thanksRepository.count(reportId); boolean isThanked = viewer != null && thanksRepository.isThanked(reportId, viewer); @@ -174,7 +183,7 @@ public ReportResponse get(Long reportId, ReportActor viewer, List o @Transactional public ThanksResponse addThanks(Long reportId, ReportActor actor) { - reportRepository.getReport(reportId); + reportRepository.getReport(reportId, actor); thanksRepository.add(reportId, actor); return new ThanksResponse(thanksRepository.count(reportId), true); @@ -182,7 +191,7 @@ public ThanksResponse addThanks(Long reportId, ReportActor actor) { @Transactional public ThanksResponse removeThanks(Long reportId, ReportActor actor) { - reportRepository.getReport(reportId); + reportRepository.getReport(reportId, actor); thanksRepository.remove(reportId, actor); return new ThanksResponse(thanksRepository.count(reportId), false); diff --git a/report/src/main/java/com/nalssilog/report/application/dto/CreateReportCommand.java b/report/src/main/java/com/nalssilog/report/application/dto/CreateReportCommand.java index f3894a0..6df5220 100644 --- a/report/src/main/java/com/nalssilog/report/application/dto/CreateReportCommand.java +++ b/report/src/main/java/com/nalssilog/report/application/dto/CreateReportCommand.java @@ -1,5 +1,6 @@ package com.nalssilog.report.application.dto; +import com.nalssilog.member.application.dto.TermsAgreement; import com.nalssilog.report.domain.Precipitation; import com.nalssilog.report.domain.Sunlight; import com.nalssilog.report.domain.Temperature; @@ -11,6 +12,7 @@ public record CreateReportCommand( Precipitation precipitation, Sunlight sunlight, String comment, - List imageKeys + List imageKeys, + List agreedTerms ) { } diff --git a/report/src/main/java/com/nalssilog/report/application/dto/ReportActor.java b/report/src/main/java/com/nalssilog/report/application/dto/ReportActor.java index 8c17608..96a98f3 100644 --- a/report/src/main/java/com/nalssilog/report/application/dto/ReportActor.java +++ b/report/src/main/java/com/nalssilog/report/application/dto/ReportActor.java @@ -1,6 +1,7 @@ package com.nalssilog.report.application.dto; import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.WeatherReport; /** * 제보 작성·감사해요의 주체. 회원(memberId) 또는 익명(anonymousKey). @@ -16,6 +17,12 @@ public static ReportActor anonymous(String anonymousKey) { return new ReportActor(ActorType.ANONYMOUS, null, anonymousKey); } + public static ReportActor authorOf(WeatherReport report) { + return report.getAuthorType() == ActorType.MEMBER + ? member(report.getAuthorMemberId()) + : anonymous(report.getAuthorAnonymousKey()); + } + public String actorKey() { return type == ActorType.MEMBER ? String.valueOf(memberId) : anonymousKey; } diff --git a/report/src/main/java/com/nalssilog/report/config/ReportClientIpResolver.java b/report/src/main/java/com/nalssilog/report/config/ReportClientIpResolver.java new file mode 100644 index 0000000..8176d65 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/config/ReportClientIpResolver.java @@ -0,0 +1,26 @@ +package com.nalssilog.report.config; + +import com.nalssilog.common.web.TrustedProxyChain; +import com.nalssilog.member.config.FeedbackRateLimitProperties; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +@Component +public class ReportClientIpResolver { + + private final TrustedProxyChain trustedProxyChain; + + public ReportClientIpResolver( + ReportRateLimitProperties properties, + FeedbackRateLimitProperties fallbackProperties + ) { + this.trustedProxyChain = new TrustedProxyChain( + properties.trustedProxies().isEmpty() + ? fallbackProperties.trustedProxies() + : properties.trustedProxies()); + } + + public String resolve(HttpServletRequest request) { + return trustedProxyChain.resolve(request); + } +} diff --git a/report/src/main/java/com/nalssilog/report/config/ReportRateLimitProperties.java b/report/src/main/java/com/nalssilog/report/config/ReportRateLimitProperties.java new file mode 100644 index 0000000..9d10751 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/config/ReportRateLimitProperties.java @@ -0,0 +1,47 @@ +package com.nalssilog.report.config; + +import java.time.Duration; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "nalssilog.report.rate-limit") +public record ReportRateLimitProperties( + int createMaxRequests, + Duration createWindow, + int createDailyMaxRequests, + int presignMaxRequests, + Duration presignWindow, + int presignDailyMaxRequests, + int flagMaxRequests, + Duration flagWindow, + int flagDailyMaxRequests, + int ipMultiplier, + String hmacSecret, + List trustedProxies +) { + + public ReportRateLimitProperties { + createMaxRequests = positiveOr(createMaxRequests, 5); + createWindow = validOr(createWindow, Duration.ofMinutes(10)); + createDailyMaxRequests = positiveOr(createDailyMaxRequests, 20); + presignMaxRequests = positiveOr(presignMaxRequests, 15); + presignWindow = validOr(presignWindow, Duration.ofMinutes(10)); + presignDailyMaxRequests = positiveOr(presignDailyMaxRequests, 60); + flagMaxRequests = positiveOr(flagMaxRequests, 10); + flagWindow = validOr(flagWindow, Duration.ofMinutes(10)); + flagDailyMaxRequests = positiveOr(flagDailyMaxRequests, 50); + ipMultiplier = positiveOr(ipMultiplier, 6); + hmacSecret = hmacSecret == null || hmacSecret.isBlank() + ? null + : hmacSecret; + trustedProxies = trustedProxies == null ? List.of() : List.copyOf(trustedProxies); + } + + private static int positiveOr(int value, int fallback) { + return value > 0 ? value : fallback; + } + + private static Duration validOr(Duration value, Duration fallback) { + return value == null || value.isNegative() || value.isZero() ? fallback : value; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ActorBlock.java b/report/src/main/java/com/nalssilog/report/domain/ActorBlock.java new file mode 100644 index 0000000..9898ba7 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ActorBlock.java @@ -0,0 +1,60 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.common.domain.BaseTimeEntity; +import com.nalssilog.report.application.dto.ReportActor; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "actor_block", + uniqueConstraints = @UniqueConstraint( + name = "uk_actor_block_pair", + columnNames = {"blocker_type", "blocker_key", "blocked_type", "blocked_key"}), + indexes = { + @Index(name = "idx_actor_block_blocker", columnList = "blocker_type, blocker_key"), + @Index(name = "idx_actor_block_blocked", columnList = "blocked_type, blocked_key") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActorBlock extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "blocker_type", nullable = false, length = 20) + private ActorType blockerType; + + @Column(name = "blocker_key", nullable = false, length = 64) + private String blockerKey; + + @Enumerated(EnumType.STRING) + @Column(name = "blocked_type", nullable = false, length = 20) + private ActorType blockedType; + + @Column(name = "blocked_key", nullable = false, length = 64) + private String blockedKey; + + public static ActorBlock create(ReportActor blocker, ReportActor blocked) { + ActorBlock block = new ActorBlock(); + + block.blockerType = blocker.type(); + block.blockerKey = blocker.actorKey(); + block.blockedType = blocked.type(); + block.blockedKey = blocked.actorKey(); + + return block; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ActorRestriction.java b/report/src/main/java/com/nalssilog/report/domain/ActorRestriction.java new file mode 100644 index 0000000..24867bb --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ActorRestriction.java @@ -0,0 +1,82 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.common.domain.BaseTimeEntity; +import com.nalssilog.report.application.dto.ReportActor; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "actor_restriction", + indexes = @Index( + name = "idx_actor_restriction_actor", + columnList = "actor_type, actor_key, lifted_at, expires_at")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActorRestriction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "actor_type", nullable = false, length = 20) + private ActorType actorType; + + @Column(name = "actor_key", nullable = false, length = 64) + private String actorKey; + + @Column(name = "source_report_id") + private Long sourceReportId; + + @Column(name = "reason", nullable = false, length = 500) + private String reason; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(name = "created_by_member_id", nullable = false) + private Long createdByMemberId; + + @Column(name = "lifted_at") + private Instant liftedAt; + + @Column(name = "lifted_by_member_id") + private Long liftedByMemberId; + + public static ActorRestriction create( + ReportActor actor, + Long sourceReportId, + String reason, + Instant expiresAt, + Long adminMemberId + ) { + ActorRestriction restriction = new ActorRestriction(); + + restriction.actorType = actor.type(); + restriction.actorKey = actor.actorKey(); + restriction.sourceReportId = sourceReportId; + restriction.reason = reason; + restriction.expiresAt = expiresAt; + restriction.createdByMemberId = adminMemberId; + + return restriction; + } + + public void lift(Long adminMemberId) { + if (liftedAt == null) { + liftedAt = Instant.now(); + liftedByMemberId = adminMemberId; + } + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ModerationAction.java b/report/src/main/java/com/nalssilog/report/domain/ModerationAction.java new file mode 100644 index 0000000..9ac5e76 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ModerationAction.java @@ -0,0 +1,70 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.common.domain.BaseTimeEntity; +import com.nalssilog.report.application.dto.ReportActor; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "moderation_action", + indexes = { + @Index(name = "idx_moderation_action_report", columnList = "report_id, created_at"), + @Index(name = "idx_moderation_action_admin", columnList = "admin_member_id, created_at") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ModerationAction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "action_type", nullable = false, length = 40) + private ModerationActionType actionType; + + @Column(name = "admin_member_id", nullable = false) + private Long adminMemberId; + + @Column(name = "report_id") + private Long reportId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_actor_type", length = 20) + private ActorType targetActorType; + + @Column(name = "target_actor_key", length = 64) + private String targetActorKey; + + @Column(name = "reason", nullable = false, length = 500) + private String reason; + + public static ModerationAction create( + ModerationActionType type, + Long adminMemberId, + Long reportId, + ReportActor target, + String reason + ) { + ModerationAction action = new ModerationAction(); + + action.actionType = type; + action.adminMemberId = adminMemberId; + action.reportId = reportId; + action.targetActorType = target == null ? null : target.type(); + action.targetActorKey = target == null ? null : target.actorKey(); + action.reason = reason; + + return action; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ModerationActionType.java b/report/src/main/java/com/nalssilog/report/domain/ModerationActionType.java new file mode 100644 index 0000000..5c16add --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ModerationActionType.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.domain; + +public enum ModerationActionType { + REPORT_HIDDEN, + REPORT_RESTORED, + REPORT_REMOVED, + FLAG_RESOLVED, + FLAG_REJECTED, + AUTHOR_RESTRICTED, + AUTHOR_RESTRICTION_LIFTED +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ModerationStatus.java b/report/src/main/java/com/nalssilog/report/domain/ModerationStatus.java new file mode 100644 index 0000000..6f8edc8 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ModerationStatus.java @@ -0,0 +1,7 @@ +package com.nalssilog.report.domain; + +public enum ModerationStatus { + VISIBLE, + HIDDEN, + REMOVED +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportConsent.java b/report/src/main/java/com/nalssilog/report/domain/ReportConsent.java new file mode 100644 index 0000000..7527a18 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ReportConsent.java @@ -0,0 +1,70 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.common.domain.BaseTimeEntity; +import com.nalssilog.member.domain.TermsType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 비회원 제보 시 동의한 필수 약관의 종류, 문서 버전, 서버 수신 시각을 보관한다. + */ +@Entity +@Table(name = "report_consent", + indexes = @Index(name = "idx_report_consent_report", columnList = "report_id"), + uniqueConstraints = @UniqueConstraint(name = "uk_report_consent_report_type", + columnNames = {"report_id", "terms_type"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReportConsent extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "report_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_report_consent_report")) + private WeatherReport report; + + @Enumerated(EnumType.STRING) + @Column(name = "terms_type", nullable = false, length = 20) + private TermsType termsType; + + @Column(name = "version", nullable = false, length = 20) + private String version; + + @Column(name = "agreed_at", nullable = false, updatable = false) + private Instant agreedAt; + + static ReportConsent agree( + WeatherReport report, + TermsType termsType, + String version, + Instant agreedAt + ) { + ReportConsent consent = new ReportConsent(); + + consent.report = report; + consent.termsType = termsType; + consent.version = version; + consent.agreedAt = agreedAt; + + return consent; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java b/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java index 20991c3..c8f4f51 100644 --- a/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java +++ b/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java @@ -9,6 +9,33 @@ @RequiredArgsConstructor public enum ReportErrorCode implements ErrorCode { + TERMS_NOT_AGREED("TERMS_NOT_AGREED", "필수 약관에 모두 동의해야 합니다.", HttpStatus.BAD_REQUEST), + REPORT_RATE_LIMITED("REPORT_RATE_LIMITED", "제보를 너무 자주 작성했습니다. 잠시 후 다시 시도해 주세요.", + HttpStatus.TOO_MANY_REQUESTS), + IMAGE_PRESIGN_RATE_LIMITED("IMAGE_PRESIGN_RATE_LIMITED", "이미지 업로드 요청이 너무 많습니다.", + HttpStatus.TOO_MANY_REQUESTS), + REPORT_FLAG_RATE_LIMITED("REPORT_FLAG_RATE_LIMITED", "신고를 너무 자주 보냈습니다. 잠시 후 다시 시도해 주세요.", + HttpStatus.TOO_MANY_REQUESTS), + RATE_LIMIT_UNAVAILABLE("RATE_LIMIT_UNAVAILABLE", "요청 제한을 확인할 수 없습니다. 잠시 후 다시 시도해 주세요.", + HttpStatus.SERVICE_UNAVAILABLE), + REPORT_ALREADY_FLAGGED("REPORT_ALREADY_FLAGGED", "이미 신고한 제보입니다.", HttpStatus.CONFLICT), + CANNOT_FLAG_OWN_REPORT("CANNOT_FLAG_OWN_REPORT", "본인의 제보는 신고할 수 없습니다.", HttpStatus.BAD_REQUEST), + REPORT_FLAG_NOT_FOUND("REPORT_FLAG_NOT_FOUND", "신고 내역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + REPORT_FLAG_ALREADY_PROCESSED("REPORT_FLAG_ALREADY_PROCESSED", "이미 처리된 신고입니다.", HttpStatus.CONFLICT), + INVALID_REPORT_FLAG_STATUS("INVALID_REPORT_FLAG_STATUS", "신고 처리 상태가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + CANNOT_BLOCK_SELF("CANNOT_BLOCK_SELF", "본인은 차단할 수 없습니다.", HttpStatus.BAD_REQUEST), + BLOCK_MEMBER_REQUIRED("BLOCK_MEMBER_REQUIRED", "차단 기능은 로그인한 회원만 사용할 수 있습니다.", + HttpStatus.FORBIDDEN), + BLOCK_TARGET_MEMBER_NOT_FOUND("BLOCK_TARGET_MEMBER_NOT_FOUND", "차단할 회원을 찾을 수 없습니다.", + HttpStatus.NOT_FOUND), + BLOCK_LIMIT_REACHED("BLOCK_LIMIT_REACHED", "차단 가능한 작성자 수를 초과했습니다.", HttpStatus.CONFLICT), + ACTOR_POSTING_RESTRICTED("ACTOR_POSTING_RESTRICTED", "운영 정책에 따라 제보 작성이 제한되었습니다.", + HttpStatus.FORBIDDEN), + AUTHOR_ALREADY_RESTRICTED("AUTHOR_ALREADY_RESTRICTED", "이미 작성 제한 중인 작성자입니다.", HttpStatus.CONFLICT), + AUTHOR_RESTRICTION_NOT_FOUND("AUTHOR_RESTRICTION_NOT_FOUND", "활성 작성 제한을 찾을 수 없습니다.", + HttpStatus.NOT_FOUND), + INVALID_RESTRICTION_EXPIRY("INVALID_RESTRICTION_EXPIRY", "작성 제한 만료 시각은 현재보다 이후여야 합니다.", + HttpStatus.BAD_REQUEST), REPORT_NOT_FOUND("REPORT_NOT_FOUND", "제보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), REPORT_DELETE_FORBIDDEN("REPORT_DELETE_FORBIDDEN", "본인이 작성한 제보만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN), diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportFlag.java b/report/src/main/java/com/nalssilog/report/domain/ReportFlag.java new file mode 100644 index 0000000..bb39e4b --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ReportFlag.java @@ -0,0 +1,104 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.common.domain.BaseTimeEntity; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.application.dto.ReportActor; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "report_flag", + uniqueConstraints = @UniqueConstraint( + name = "uk_report_flag_report_reporter", + columnNames = {"report_id", "reporter_type", "reporter_key"}), + indexes = { + @Index(name = "idx_report_flag_status_created", columnList = "status, created_at"), + @Index(name = "idx_report_flag_report", columnList = "report_id") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReportFlag extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "report_id", nullable = false) + private WeatherReport report; + + @Enumerated(EnumType.STRING) + @Column(name = "reporter_type", nullable = false, length = 20) + private ActorType reporterType; + + @Column(name = "reporter_key", nullable = false, length = 64) + private String reporterKey; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 30) + private ReportFlagReason reason; + + @Column(name = "detail", length = 500) + private String detail; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ReportFlagStatus status; + + @Column(name = "processed_at") + private Instant processedAt; + + @Column(name = "processed_by_member_id") + private Long processedByMemberId; + + @Column(name = "resolution_note", length = 500) + private String resolutionNote; + + public static ReportFlag create( + WeatherReport report, + ReportActor reporter, + ReportFlagReason reason, + String detail + ) { + ReportFlag flag = new ReportFlag(); + + flag.report = report; + flag.reporterType = reporter.type(); + flag.reporterKey = reporter.actorKey(); + flag.reason = reason; + flag.detail = detail; + flag.status = ReportFlagStatus.PENDING; + + return flag; + } + + public void process(ReportFlagStatus result, Long adminMemberId, String note) { + if (status != ReportFlagStatus.PENDING) { + throw new NalssiLogException(ReportErrorCode.REPORT_FLAG_ALREADY_PROCESSED); + } + + if (result != ReportFlagStatus.RESOLVED && result != ReportFlagStatus.REJECTED) { + throw new NalssiLogException(ReportErrorCode.INVALID_REPORT_FLAG_STATUS); + } + + status = result; + processedAt = Instant.now(); + processedByMemberId = adminMemberId; + resolutionNote = note; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportFlagReason.java b/report/src/main/java/com/nalssilog/report/domain/ReportFlagReason.java new file mode 100644 index 0000000..5fbc6d7 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ReportFlagReason.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.domain; + +public enum ReportFlagReason { + SPAM, + ABUSE, + HATE, + SEXUAL, + PRIVACY, + FALSE_INFORMATION, + OTHER +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportFlagStatus.java b/report/src/main/java/com/nalssilog/report/domain/ReportFlagStatus.java new file mode 100644 index 0000000..b455be1 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ReportFlagStatus.java @@ -0,0 +1,7 @@ +package com.nalssilog.report.domain; + +public enum ReportFlagStatus { + PENDING, + RESOLVED, + REJECTED +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportModerationCommand.java b/report/src/main/java/com/nalssilog/report/domain/ReportModerationCommand.java new file mode 100644 index 0000000..a212c54 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/ReportModerationCommand.java @@ -0,0 +1,7 @@ +package com.nalssilog.report.domain; + +public enum ReportModerationCommand { + HIDE, + RESTORE, + REMOVE +} diff --git a/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java b/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java index 4667a16..edc8b0b 100644 --- a/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java +++ b/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java @@ -1,6 +1,7 @@ package com.nalssilog.report.domain; import com.nalssilog.common.domain.BaseTimeEntity; +import com.nalssilog.member.domain.TermsType; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -12,6 +13,7 @@ import jakarta.persistence.Index; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -61,9 +63,16 @@ public class WeatherReport extends BaseTimeEntity { @Column(name = "comment", nullable = true, length = 200) private String comment; + @Enumerated(EnumType.STRING) + @Column(name = "moderation_status", nullable = false, length = 20) + private ModerationStatus moderationStatus; + @OneToMany(mappedBy = "report", cascade = CascadeType.ALL, orphanRemoval = true) private List images = new ArrayList<>(); + @OneToMany(mappedBy = "report", cascade = CascadeType.ALL, orphanRemoval = true) + private List consents = new ArrayList<>(); + public static WeatherReport ofMember(Long locationId, Long memberId, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = base(locationId, temperature, precipitation, sunlight, comment); @@ -90,6 +99,21 @@ public void addImages(List storageKeys) { } } + public void addConsent(TermsType termsType, String version, Instant agreedAt) { + if (authorType != ActorType.ANONYMOUS) { + throw new IllegalStateException("Only anonymous reports can record report consent."); + } + + boolean alreadyRecorded = consents.stream() + .anyMatch(consent -> consent.getTermsType() == termsType); + + if (alreadyRecorded) { + throw new IllegalArgumentException("A report consent type can only be recorded once."); + } + + consents.add(ReportConsent.agree(this, termsType, version, agreedAt)); + } + private static WeatherReport base(Long locationId, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = new WeatherReport(); @@ -99,7 +123,20 @@ private static WeatherReport base(Long locationId, Temperature temperature, Prec report.precipitation = precipitation; report.sunlight = sunlight; report.comment = comment; + report.moderationStatus = ModerationStatus.VISIBLE; return report; } + + public void hide() { + this.moderationStatus = ModerationStatus.HIDDEN; + } + + public void restore() { + this.moderationStatus = ModerationStatus.VISIBLE; + } + + public void removeByModerator() { + this.moderationStatus = ModerationStatus.REMOVED; + } } diff --git a/report/src/main/java/com/nalssilog/report/repository/ActorBlockJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/ActorBlockJpaRepository.java new file mode 100644 index 0000000..0a605c3 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/ActorBlockJpaRepository.java @@ -0,0 +1,19 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.ActorBlock; +import com.nalssilog.report.domain.ActorType; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActorBlockJpaRepository extends JpaRepository { + + Optional findByBlockerTypeAndBlockerKeyAndBlockedTypeAndBlockedKey( + ActorType blockerType, String blockerKey, ActorType blockedType, String blockedKey); + + long countByBlockerTypeAndBlockerKey(ActorType blockerType, String blockerKey); + + Page findAllByBlockerTypeAndBlockerKeyOrderByCreatedAtDesc( + ActorType blockerType, String blockerKey, Pageable pageable); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/ActorRestrictionJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/ActorRestrictionJpaRepository.java new file mode 100644 index 0000000..1af987e --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/ActorRestrictionJpaRepository.java @@ -0,0 +1,26 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.ActorRestriction; +import com.nalssilog.report.domain.ActorType; +import java.time.Instant; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ActorRestrictionJpaRepository extends JpaRepository { + + @Query(""" + select restriction + from ActorRestriction restriction + where restriction.actorType = :actorType + and restriction.actorKey = :actorKey + and restriction.liftedAt is null + and (restriction.expiresAt is null or restriction.expiresAt > :now) + order by restriction.createdAt desc + """) + List findActive( + @Param("actorType") ActorType actorType, + @Param("actorKey") String actorKey, + @Param("now") Instant now); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/ModerationActionJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/ModerationActionJpaRepository.java new file mode 100644 index 0000000..327c068 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/ModerationActionJpaRepository.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.ModerationAction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ModerationActionJpaRepository extends JpaRepository { + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/ReportFlagJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/ReportFlagJpaRepository.java new file mode 100644 index 0000000..d4d4a5f --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/ReportFlagJpaRepository.java @@ -0,0 +1,24 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.ReportFlag; +import com.nalssilog.report.domain.ReportFlagStatus; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportFlagJpaRepository extends JpaRepository { + + boolean existsByReport_IdAndReporterTypeAndReporterKey( + Long reportId, ActorType reporterType, String reporterKey); + + @EntityGraph(attributePaths = "report") + Page findAllByOrderByCreatedAtAsc(Pageable pageable); + + @EntityGraph(attributePaths = "report") + Page findAllByStatusOrderByCreatedAtAsc(ReportFlagStatus status, Pageable pageable); + + List findAllByReport_IdAndStatus(Long reportId, ReportFlagStatus status); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java index 7b3b995..b0e2fd9 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java @@ -1,6 +1,7 @@ package com.nalssilog.report.repository; import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.ModerationStatus; import com.nalssilog.report.domain.WeatherReport; import java.time.Instant; import java.util.List; @@ -18,5 +19,6 @@ public interface WeatherReportJpaRepository extends JpaRepository findAllByAuthorTypeAndAuthorMemberIdOrderByCreatedAtDescIdDesc( ActorType authorType, Long authorMemberId, Pageable pageable); - long countByLocationIdAndCreatedAtGreaterThanEqual(Long locationId, Instant since); + long countByLocationIdAndModerationStatusAndCreatedAtGreaterThanEqual( + Long locationId, ModerationStatus moderationStatus, Instant since); } diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java index c204560..c403c8b 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java @@ -2,13 +2,16 @@ import static com.nalssilog.report.domain.QWeatherReport.weatherReport; import static com.nalssilog.report.domain.QWeatherReportImage.weatherReportImage; +import static com.nalssilog.report.domain.QActorBlock.actorBlock; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.report.application.dto.PopularLocationAggregate; import com.nalssilog.report.application.dto.ReportData; +import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.application.dto.WeatherStatsData; import com.nalssilog.report.domain.ActorType; import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.ModerationStatus; import com.nalssilog.report.domain.ReportErrorCode; import com.nalssilog.report.domain.Sunlight; import com.nalssilog.report.domain.Temperature; @@ -18,7 +21,10 @@ import com.querydsl.core.types.dsl.DateTimeExpression; import com.querydsl.core.types.dsl.EnumPath; import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.jpa.JPAExpressions; import jakarta.persistence.EntityManager; import java.time.Instant; import java.util.EnumMap; @@ -28,8 +34,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; /** @@ -71,17 +75,36 @@ public int anonymizeAuthor(Long memberId) { } public ReportData getReport(Long reportId) { - return ReportData.of(getReportEntity(reportId)); + return getReport(reportId, null); + } + + public ReportData getReport(Long reportId, ReportActor viewer) { + return ReportData.of(getVisibleReportEntity(reportId, viewer)); } /** 삭제처럼 관리 엔티티가 필요한 쓰기 유스케이스 전용. 반드시 트랜잭션 안에서 사용한다. */ public WeatherReport getReportEntity(Long reportId) { + return findReportEntity(reportId, false, null); + } + + public WeatherReport getVisibleReportEntity(Long reportId) { + return getVisibleReportEntity(reportId, null); + } + + public WeatherReport getVisibleReportEntity(Long reportId, ReportActor viewer) { + return findReportEntity(reportId, true, viewer); + } + + private WeatherReport findReportEntity(Long reportId, boolean visibleOnly, ReportActor viewer) { WeatherReport report = queryFactory .selectFrom(weatherReport) .distinct() .leftJoin(weatherReport.images, weatherReportImage) .fetchJoin() - .where(weatherReport.id.eq(reportId)) + .where( + weatherReport.id.eq(reportId), + visibleOnly ? weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE) : null, + visibleOnly ? withoutBlockRelation(viewer) : null) .fetchOne(); if (report == null) { @@ -96,10 +119,26 @@ public void delete(WeatherReport report) { } public List findPage(Long locationId, Instant cursorTime, Long cursorId, int limit) { - Pageable pageable = PageRequest.of(0, limit); - List reports = cursorTime == null - ? weatherReportJpaRepository.findAllByLocationIdOrderByCreatedAtDescIdDesc(locationId, pageable) - : findAfterLocationCursor(locationId, cursorTime, cursorId, limit); + return findPage(locationId, cursorTime, cursorId, null, limit); + } + + public List findPage( + Long locationId, + Instant cursorTime, + Long cursorId, + ReportActor viewer, + int limit + ) { + List reports = queryFactory + .selectFrom(weatherReport) + .where( + weatherReport.locationId.eq(locationId), + weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE), + cursorTime == null ? null : beforeCursor(cursorTime, cursorId), + withoutBlockRelation(viewer)) + .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) + .limit(limit) + .fetch(); return fetchImages(reports).stream() .map(ReportData::of) @@ -107,11 +146,27 @@ public List findPage(Long locationId, Instant cursorTime, Long curso } public List findMemberPage(Long memberId, Instant cursorTime, Long cursorId, int limit) { - Pageable pageable = PageRequest.of(0, limit); - List reports = cursorTime == null - ? weatherReportJpaRepository.findAllByAuthorTypeAndAuthorMemberIdOrderByCreatedAtDescIdDesc( - ActorType.MEMBER, memberId, pageable) - : findAfterMemberCursor(memberId, cursorTime, cursorId, limit); + return findMemberPage(memberId, cursorTime, cursorId, null, limit); + } + + public List findMemberPage( + Long memberId, + Instant cursorTime, + Long cursorId, + ReportActor viewer, + int limit + ) { + List reports = queryFactory + .selectFrom(weatherReport) + .where( + weatherReport.authorType.eq(ActorType.MEMBER), + weatherReport.authorMemberId.eq(memberId), + weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE), + cursorTime == null ? null : beforeCursor(cursorTime, cursorId), + withoutBlockRelation(viewer)) + .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) + .limit(limit) + .fetch(); return fetchImages(reports).stream() .map(ReportData::of) @@ -136,7 +191,8 @@ public List findPopularLocationAggregates( .from(weatherReport) .where( weatherReport.createdAt.goe(windowStartedAt), - weatherReport.createdAt.lt(windowEndedAt)) + weatherReport.createdAt.lt(windowEndedAt), + weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE)) .groupBy(weatherReport.locationId) .orderBy( uniqueReporterCount.desc(), @@ -158,7 +214,8 @@ public List findPopularLocationAggregates( /** 최근({@code since} 이후) 제보의 3축 분포 + 제보 수 집계. */ public WeatherStatsData statsSince(Long locationId, Instant since) { long reportCount = weatherReportJpaRepository - .countByLocationIdAndCreatedAtGreaterThanEqual(locationId, since); + .countByLocationIdAndModerationStatusAndCreatedAtGreaterThanEqual( + locationId, ModerationStatus.VISIBLE, since); return new WeatherStatsData( reportCount, @@ -168,33 +225,6 @@ public WeatherStatsData statsSince(Long locationId, Instant since) { ); } - private List findAfterLocationCursor( - Long locationId, Instant cursorTime, Long cursorId, int limit) { - return queryFactory - .selectFrom(weatherReport) - .where( - weatherReport.locationId.eq(locationId), - beforeCursor(cursorTime, cursorId) - ) - .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) - .limit(limit) - .fetch(); - } - - private List findAfterMemberCursor( - Long memberId, Instant cursorTime, Long cursorId, int limit) { - return queryFactory - .selectFrom(weatherReport) - .where( - weatherReport.authorType.eq(ActorType.MEMBER), - weatherReport.authorMemberId.eq(memberId), - beforeCursor(cursorTime, cursorId) - ) - .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) - .limit(limit) - .fetch(); - } - /** * 컬렉션 fetch join에 페이지 제한을 직접 적용하면 메모리 페이징이 발생할 수 있다. * 먼저 루트 엔티티를 제한한 뒤 선택된 ID만 이미지와 fetch join하고 기존 순서를 복원한다. @@ -239,7 +269,8 @@ private > Map countByAxis( .from(weatherReport) .where( weatherReport.locationId.eq(locationId), - weatherReport.createdAt.goe(since) + weatherReport.createdAt.goe(since), + weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE) ) .groupBy(axis) .fetch(); @@ -254,4 +285,31 @@ private > Map countByAxis( private static long value(Long value) { return value == null ? 0 : value; } + + BooleanExpression withoutBlockRelation(ReportActor viewer) { + if (viewer == null || viewer.type() != ActorType.MEMBER) { + return null; + } + + StringExpression authorKey = new CaseBuilder() + .when(weatherReport.authorType.eq(ActorType.MEMBER)) + .then(weatherReport.authorMemberId.stringValue()) + .otherwise(weatherReport.authorAnonymousKey); + + BooleanExpression viewerBlocksAuthor = + actorBlock.blockerType.eq(viewer.type()) + .and(actorBlock.blockerKey.eq(viewer.actorKey())) + .and(actorBlock.blockedType.eq(weatherReport.authorType)) + .and(actorBlock.blockedKey.eq(authorKey)); + BooleanExpression authorBlocksViewer = + actorBlock.blockerType.eq(weatherReport.authorType) + .and(actorBlock.blockerKey.eq(authorKey)) + .and(actorBlock.blockedType.eq(viewer.type())) + .and(actorBlock.blockedKey.eq(viewer.actorKey())); + + return JPAExpressions.selectOne() + .from(actorBlock) + .where(viewerBlocksAuthor.or(authorBlocksViewer)) + .notExists(); + } } diff --git a/report/src/test/java/com/nalssilog/report/api/dto/CreateReportRequestTest.java b/report/src/test/java/com/nalssilog/report/api/dto/CreateReportRequestTest.java new file mode 100644 index 0000000..3830fc3 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/api/dto/CreateReportRequestTest.java @@ -0,0 +1,50 @@ +package com.nalssilog.report.api.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.TermsType; +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CreateReportRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void validatesNestedTermAgreementFields() { + CreateReportRequest request = request(List.of( + new TermsAgreement(TermsType.SERVICE, " "), + new TermsAgreement(TermsType.PRIVACY, "1.0") + )); + + assertThat(validator.validate(request)) + .anySatisfy(violation -> assertThat(violation.getPropertyPath().toString()) + .contains("agreedTerms")); + } + + @Test + void keepsTermsOptionalAtDtoLevelForMemberReports() { + CreateReportRequest request = request(null); + + assertThat(validator.validate(request)).isEmpty(); + assertThat(request.toCommand().agreedTerms()).isEmpty(); + } + + private CreateReportRequest request(List agreedTerms) { + return new CreateReportRequest( + 1L, + Temperature.FRESH, + Precipitation.NONE, + Sunlight.MODERATE, + "맑아요", + List.of(), + agreedTerms + ); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ActorBlockServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ActorBlockServiceTest.java new file mode 100644 index 0000000..7c1474e --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/ActorBlockServiceTest.java @@ -0,0 +1,154 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.report.application.dto.AuthorInfo; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.client.MemberClient; +import com.nalssilog.report.domain.ActorBlock; +import com.nalssilog.report.domain.ActorType; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.repository.ActorBlockJpaRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@SuppressWarnings("java:S5960") +class ActorBlockServiceTest { + + private final ActorBlockJpaRepository blockRepository = mock(ActorBlockJpaRepository.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final ActorBlockService service = new ActorBlockService( + blockRepository, memberClient); + + @Test + void cannotBlockSelf() { + when(memberClient.findActiveAuthor(7L)).thenReturn(Optional.of( + new AuthorInfo(7L, "이웃", AvatarType.DEFAULT, null))); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.blockMember(7L, ReportActor.member(7L))); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.CANNOT_BLOCK_SELF); + verify(blockRepository, never()).saveAndFlush(any()); + } + + @Test + void guestCannotBlockMember() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.blockMember(7L, ReportActor.anonymous("viewer"))); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.BLOCK_MEMBER_REQUIRED); + verify(memberClient, never()).findActiveAuthor(any()); + verify(blockRepository, never()).saveAndFlush(any()); + } + + @Test + void memberCanBlockMemberFromProfile() { + when(memberClient.findActiveAuthor(7L)).thenReturn(Optional.of( + new AuthorInfo(7L, "이웃", AvatarType.PRESET, "avatar-01"))); + when(blockRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThat(service.blockMember(7L, ReportActor.member(8L)).blocked()).isTrue(); + + verify(blockRepository).saveAndFlush(any(ActorBlock.class)); + } + + @Test + void missingOrInactiveMemberCannotBeBlockedByMemberId() { + when(memberClient.findActiveAuthor(7L)).thenReturn(Optional.empty()); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.blockMember(7L, ReportActor.member(8L))); + + assertThat(exception.getErrorCode()) + .isEqualTo(ReportErrorCode.BLOCK_TARGET_MEMBER_NOT_FOUND); + verify(blockRepository, never()).saveAndFlush(any()); + } + + @Test + void blockListIncludesMemberAvatarContract() { + ActorBlock block = ActorBlock.create( + ReportActor.member(8L), ReportActor.member(7L)); + + when(blockRepository.findAllByBlockerTypeAndBlockerKeyOrderByCreatedAtDesc( + any(), any(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(block))); + when(memberClient.findActiveAuthor(7L)).thenReturn(Optional.of( + new AuthorInfo(7L, "이웃", AvatarType.PRESET, "avatar-01"))); + + var item = service.list(ReportActor.member(8L), 0, 20).items().getFirst(); + + assertThat(item.nickname()).isEqualTo("이웃"); + assertThat(item.avatar().type()).isEqualTo(AvatarType.PRESET); + assertThat(item.avatar().value()).isEqualTo("avatar-01"); + } + + @Test + void memberCannotViewProfileOfMemberTheyBlocked() { + ActorBlock block = ActorBlock.create( + ReportActor.member(8L), ReportActor.member(7L)); + + when(blockRepository.findByBlockerTypeAndBlockerKeyAndBlockedTypeAndBlockedKey( + ActorType.MEMBER, + "8", + ActorType.MEMBER, + "7")) + .thenReturn(Optional.of(block)); + + assertThat(service.canView(8L, 7L)).isFalse(); + } + + @Test + void memberCannotViewProfileOfMemberWhoBlockedThem() { + ActorBlock reverseBlock = ActorBlock.create( + ReportActor.member(7L), ReportActor.member(8L)); + + when(blockRepository.findByBlockerTypeAndBlockerKeyAndBlockedTypeAndBlockedKey( + ActorType.MEMBER, + "8", + ActorType.MEMBER, + "7")) + .thenReturn(Optional.empty()); + when(blockRepository.findByBlockerTypeAndBlockerKeyAndBlockedTypeAndBlockedKey( + ActorType.MEMBER, + "7", + ActorType.MEMBER, + "8")) + .thenReturn(Optional.of(reverseBlock)); + + assertThat(service.canView(8L, 7L)).isFalse(); + } + + @Test + void memberCanViewProfileWhenNoBlockRelationExists() { + when(blockRepository.findByBlockerTypeAndBlockerKeyAndBlockedTypeAndBlockedKey( + any(), any(), any(), any())) + .thenReturn(Optional.empty()); + + assertThat(service.canView(8L, 7L)).isTrue(); + } + + @Test + void anonymousAndSelfProfileViewsDoNotNeedBlockLookup() { + assertThat(service.canView(null, 7L)).isTrue(); + assertThat(service.canView(7L, 7L)).isTrue(); + + verifyNoInteractions(blockRepository); + } + +} diff --git a/report/src/test/java/com/nalssilog/report/application/AdminModerationServiceTest.java b/report/src/test/java/com/nalssilog/report/application/AdminModerationServiceTest.java new file mode 100644 index 0000000..7287e73 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/AdminModerationServiceTest.java @@ -0,0 +1,83 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.api.dto.AdminModerateReportRequest; +import com.nalssilog.report.api.dto.AdminRestrictAuthorRequest; +import com.nalssilog.report.client.ImageStorageClient; +import com.nalssilog.report.domain.ModerationAction; +import com.nalssilog.report.domain.ModerationStatus; +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.domain.ReportModerationCommand; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; +import com.nalssilog.report.domain.WeatherReport; +import com.nalssilog.report.repository.ActorRestrictionJpaRepository; +import com.nalssilog.report.repository.ModerationActionJpaRepository; +import com.nalssilog.report.repository.ReportFlagJpaRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class AdminModerationServiceTest { + + private final WeatherReportRepository reportRepository = mock(WeatherReportRepository.class); + private final ReportFlagJpaRepository flagRepository = mock(ReportFlagJpaRepository.class); + private final ActorRestrictionJpaRepository restrictionRepository = mock(ActorRestrictionJpaRepository.class); + private final ModerationActionJpaRepository actionRepository = mock(ModerationActionJpaRepository.class); + private final ActorRestrictionService restrictionService = mock(ActorRestrictionService.class); + private final AdminModerationService service = new AdminModerationService( + reportRepository, + flagRepository, + restrictionRepository, + actionRepository, + restrictionService, + mock(ImageStorageClient.class)); + + @BeforeEach + void setUp() { + when(flagRepository.findAllByReport_IdAndStatus(any(), any())).thenReturn(List.of()); + } + + @Test + void hideChangesPublicStateAndWritesAuditAction() { + WeatherReport report = report(); + when(reportRepository.getReportEntity(1L)).thenReturn(report); + + var response = service.moderate( + 1L, + 99L, + new AdminModerateReportRequest(ReportModerationCommand.HIDE, "운영 정책 위반")); + + assertThat(response.status()).isEqualTo(ModerationStatus.HIDDEN); + assertThat(report.getModerationStatus()).isEqualTo(ModerationStatus.HIDDEN); + verify(actionRepository).save(any(ModerationAction.class)); + } + + @Test + void restrictionExpiryMustBeInFuture() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.restrictAuthor( + 1L, + 99L, + new AdminRestrictAuthorRequest("도배", Instant.now().minusSeconds(1)))); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.INVALID_RESTRICTION_EXPIRY); + } + + private WeatherReport report() { + return WeatherReport.ofAnonymous( + 1L, "guest", Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE, "맑아요"); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportConsentPolicyTest.java b/report/src/test/java/com/nalssilog/report/application/ReportConsentPolicyTest.java new file mode 100644 index 0000000..e118e03 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/ReportConsentPolicyTest.java @@ -0,0 +1,71 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.TermsType; +import com.nalssilog.report.domain.ReportErrorCode; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ReportConsentPolicyTest { + + private final ReportConsentPolicy policy = new ReportConsentPolicy(); + + @Test + void acceptsBothRequiredTermsAndNormalizesVersions() { + List agreements = policy.validate(List.of( + new TermsAgreement(TermsType.PRIVACY, " privacy-2026-08 "), + new TermsAgreement(TermsType.SERVICE, " service-1.0 ") + )); + + assertThat(agreements) + .extracting(TermsAgreement::type) + .containsExactly(TermsType.SERVICE, TermsType.PRIVACY); + assertThat(agreements) + .extracting(TermsAgreement::version) + .containsExactly("service-1.0", "privacy-2026-08"); + } + + @Test + void rejectsWhenPrivacyAgreementIsMissing() { + assertTermsNotAgreed(List.of( + new TermsAgreement(TermsType.SERVICE, "1.0") + )); + } + + @Test + void rejectsMissingAgreementList() { + assertTermsNotAgreed(null); + } + + @Test + void rejectsDuplicateAgreementTypes() { + assertTermsNotAgreed(List.of( + new TermsAgreement(TermsType.SERVICE, "1.0"), + new TermsAgreement(TermsType.SERVICE, "1.0"), + new TermsAgreement(TermsType.PRIVACY, "1.0") + )); + } + + @Test + void rejectsBlankOrOversizedVersions() { + assertTermsNotAgreed(List.of( + new TermsAgreement(TermsType.SERVICE, " "), + new TermsAgreement(TermsType.PRIVACY, "1.0") + )); + assertTermsNotAgreed(List.of( + new TermsAgreement(TermsType.SERVICE, "1.0"), + new TermsAgreement(TermsType.PRIVACY, "v".repeat(21)) + )); + } + + private void assertTermsNotAgreed(List agreements) { + assertThatThrownBy(() -> policy.validate(agreements)) + .isInstanceOfSatisfying(NalssiLogException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ReportErrorCode.TERMS_NOT_AGREED)); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportFlagServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportFlagServiceTest.java new file mode 100644 index 0000000..15a251f --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/ReportFlagServiceTest.java @@ -0,0 +1,104 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.api.dto.CreateReportFlagRequest; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.report.domain.ReportFlag; +import com.nalssilog.report.domain.ReportFlagReason; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; +import com.nalssilog.report.domain.WeatherReport; +import com.nalssilog.report.repository.ReportFlagJpaRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +@SuppressWarnings("java:S5960") +class ReportFlagServiceTest { + + private final WeatherReportRepository reportRepository = mock(WeatherReportRepository.class); + private final ReportFlagJpaRepository flagRepository = mock(ReportFlagJpaRepository.class); + private final ReportFlagService service = new ReportFlagService(reportRepository, flagRepository); + + @Test + void authorCannotFlagOwnReport() { + when(reportRepository.getVisibleReportEntity(1L)).thenReturn(memberReport(7L)); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.flag(1L, ReportActor.member(7L), request())); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.CANNOT_FLAG_OWN_REPORT); + verify(flagRepository, never()).saveAndFlush(any()); + } + + @Test + void duplicateFlagIsRejected() { + when(reportRepository.getVisibleReportEntity(1L)).thenReturn(memberReport(7L)); + when(flagRepository.existsByReport_IdAndReporterTypeAndReporterKey( + 1L, ReportActor.member(8L).type(), "8")).thenReturn(true); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.flag(1L, ReportActor.member(8L), request())); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.REPORT_ALREADY_FLAGGED); + } + + @Test + void guestCanFlagAnonymousReport() { + when(reportRepository.getVisibleReportEntity(1L)).thenReturn(anonymousReport("author")); + when(flagRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.flag(1L, ReportActor.anonymous("reporter"), request()); + + verify(flagRepository).saveAndFlush(any(ReportFlag.class)); + } + + @Test + void memberCanFlagAnonymousReport() { + when(reportRepository.getVisibleReportEntity(1L)).thenReturn(anonymousReport("author")); + when(flagRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.flag(1L, ReportActor.member(8L), request()); + + verify(flagRepository).saveAndFlush(any(ReportFlag.class)); + } + + @Test + void validFlagStoresNormalizedDetail() { + when(reportRepository.getVisibleReportEntity(1L)).thenReturn(memberReport(7L)); + when(flagRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.flag(1L, ReportActor.anonymous("guest"), + new CreateReportFlagRequest(ReportFlagReason.SPAM, " 반복 게시물 ")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReportFlag.class); + verify(flagRepository).saveAndFlush(captor.capture()); + assertThat(captor.getValue().getDetail()).isEqualTo("반복 게시물"); + } + + private CreateReportFlagRequest request() { + return new CreateReportFlagRequest(ReportFlagReason.ABUSE, "욕설"); + } + + private WeatherReport memberReport(Long memberId) { + return WeatherReport.ofMember( + 1L, memberId, Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE, "맑아요"); + } + + private WeatherReport anonymousReport(String anonymousKey) { + return WeatherReport.ofAnonymous( + 1L, anonymousKey, Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE, "맑아요"); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportRateLimiterTest.java b/report/src/test/java/com/nalssilog/report/application/ReportRateLimiterTest.java new file mode 100644 index 0000000..7961cac --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/ReportRateLimiterTest.java @@ -0,0 +1,61 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.config.ReportRateLimitProperties; +import com.nalssilog.report.domain.ReportErrorCode; +import com.nalssilog.member.config.FeedbackRateLimitProperties; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +@SuppressWarnings({"java:S5960", "unchecked"}) +class ReportRateLimiterTest { + + private final StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + private final ReportRateLimiter limiter = new ReportRateLimiter( + redisTemplate, + new ReportRateLimitProperties( + 5, Duration.ofMinutes(10), 20, + 15, Duration.ofMinutes(10), 60, + 10, Duration.ofMinutes(10), 50, + 6, "test-secret", List.of()), + new FeedbackRateLimitProperties( + 5, Duration.ofMinutes(10), "fallback-secret", List.of())); + + @Test + void createLimitReturnsStableErrorCode() { + when(redisTemplate.execute( + any(RedisScript.class), anyList(), any(), any(), any(), any(), any())) + .thenReturn(1L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.checkCreate(ReportActor.anonymous("guest-id"), "198.51.100.1")); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.REPORT_RATE_LIMITED); + } + + @Test + void redisFailureFailsClosedForUgcWrite() { + when(redisTemplate.execute( + any(RedisScript.class), anyList(), any(), any(), any(), any(), any())) + .thenThrow(new RedisConnectionFailureException("test")); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.checkPresign(ReportActor.member(7L), "198.51.100.1")); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.RATE_LIMIT_UNAVAILABLE); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java index 23285bb..426b57d 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java @@ -8,12 +8,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.CursorPage; +import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.TermsType; import com.nalssilog.report.api.dto.ReportResponse; import com.nalssilog.report.application.dto.AuthorInfo; +import com.nalssilog.report.application.dto.CreateReportCommand; import com.nalssilog.report.application.dto.LocationSummary; import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.application.dto.ReportData; @@ -23,6 +27,7 @@ import com.nalssilog.report.client.MemberClient; import com.nalssilog.report.domain.ActorType; import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.ReportConsent; import com.nalssilog.report.domain.ReportErrorCode; import com.nalssilog.report.domain.Sunlight; import com.nalssilog.report.domain.Temperature; @@ -48,6 +53,7 @@ class ReportServiceTest { private final LocationClient locationClient = mock(LocationClient.class); private final ImageStorageClient imageStorageClient = mock(ImageStorageClient.class); private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + private final ReportConsentPolicy reportConsentPolicy = new ReportConsentPolicy(); private ReportService service; @@ -59,10 +65,62 @@ void setUp() { memberClient, locationClient, imageStorageClient, - eventPublisher + eventPublisher, + reportConsentPolicy ); } + @Test + void anonymousReportRequiresAndStoresBothTermAgreements() { + when(locationClient.getLocation(1L)).thenReturn(location()); + when(reportRepository.save(any())).thenReturn(anonymousData("anonymous-key")); + + service.create(ReportActor.anonymous("anonymous-key"), createCommand(List.of( + new TermsAgreement(TermsType.SERVICE, "service-1.0"), + new TermsAgreement(TermsType.PRIVACY, "privacy-2026-08") + ))); + + ArgumentCaptor reportCaptor = ArgumentCaptor.forClass(WeatherReport.class); + verify(reportRepository).save(reportCaptor.capture()); + + List consents = reportCaptor.getValue().getConsents(); + assertThat(consents) + .extracting(ReportConsent::getTermsType) + .containsExactly(TermsType.SERVICE, TermsType.PRIVACY); + assertThat(consents) + .extracting(ReportConsent::getVersion) + .containsExactly("service-1.0", "privacy-2026-08"); + assertThat(consents) + .extracting(ReportConsent::getAgreedAt) + .doesNotContainNull() + .allMatch(consents.getFirst().getAgreedAt()::equals); + } + + @Test + void anonymousReportIsRejectedBeforeOtherWorkWhenRequiredTermIsMissing() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.create(ReportActor.anonymous("anonymous-key"), createCommand(List.of( + new TermsAgreement(TermsType.SERVICE, "1.0") + ))) + ); + + assertThat(exception.getErrorCode()).isEqualTo(ReportErrorCode.TERMS_NOT_AGREED); + verifyNoInteractions(locationClient, imageStorageClient, reportRepository); + } + + @Test + void memberReportDoesNotRequireOrStoreReportConsents() { + when(locationClient.getLocation(1L)).thenReturn(location()); + when(reportRepository.save(any())).thenReturn(memberData(10L, 1L)); + + service.create(ReportActor.member(1L), createCommand(List.of())); + + ArgumentCaptor reportCaptor = ArgumentCaptor.forClass(WeatherReport.class); + verify(reportRepository).save(reportCaptor.capture()); + assertThat(reportCaptor.getValue().getConsents()).isEmpty(); + } + @Test void memberAuthorDeletesReportAndRelatedData() { WeatherReport report = memberReport(1L); @@ -118,7 +176,7 @@ void nonAuthorCannotDeleteReport() { void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { ReportData data = anonymousData("anonymous-key"); - when(reportRepository.getReport(10L)).thenReturn(data); + when(reportRepository.getReport(10L, ReportActor.member(1L))).thenReturn(data); when(locationClient.getLocation(1L)).thenReturn(location()); ReportResponse response = service.get( @@ -134,7 +192,8 @@ void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { void listIncludesOwnershipCalculatedFromAllAvailableActors() { ReportData data = anonymousData("anonymous-key"); - when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) + when(reportRepository.findPage( + eq(1L), isNull(), isNull(), eq(ReportActor.member(1L)), eq(21))) .thenReturn(List.of(data)); when(locationClient.getLocation(1L)).thenReturn(location()); when(thanksRepository.countByReportIds(List.of(10L))).thenReturn(Map.of()); @@ -162,7 +221,7 @@ void listLoadsDistinctMemberAuthorsInOneBulkCall() { AuthorInfo firstAuthor = new AuthorInfo(7L, "first", null, null); AuthorInfo secondAuthor = new AuthorInfo(8L, "second", null, null); - when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) + when(reportRepository.findPage(eq(1L), isNull(), isNull(), isNull(), eq(21))) .thenReturn(List.of(first, second)); when(locationClient.getLocation(1L)).thenReturn(location()); when(thanksRepository.countByReportIds(List.of(10L, 11L))).thenReturn(Map.of()); @@ -206,6 +265,18 @@ private WeatherReport memberReport(Long memberId) { 1L, memberId, Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE, "맑아요"); } + private CreateReportCommand createCommand(List agreedTerms) { + return new CreateReportCommand( + 1L, + Temperature.FRESH, + Precipitation.NONE, + Sunlight.MODERATE, + "맑아요", + List.of(), + agreedTerms + ); + } + private WeatherReport anonymousReport(String anonymousKey) { return WeatherReport.ofAnonymous( 1L, anonymousKey, Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE, "맑아요"); diff --git a/report/src/test/java/com/nalssilog/report/repository/WeatherReportRepositoryBlockVisibilityTest.java b/report/src/test/java/com/nalssilog/report/repository/WeatherReportRepositoryBlockVisibilityTest.java new file mode 100644 index 0000000..b2a0ff2 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/repository/WeatherReportRepositoryBlockVisibilityTest.java @@ -0,0 +1,45 @@ +package com.nalssilog.report.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.domain.QWeatherReport; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class WeatherReportRepositoryBlockVisibilityTest { + + private final WeatherReportRepository repository = new WeatherReportRepository( + mock(WeatherReportJpaRepository.class), + mock(JPAQueryFactory.class), + mock(EntityManager.class)); + + @Test + void memberFeedPredicateExcludesBlocksInBothDirections() { + BooleanExpression predicate = repository.withoutBlockRelation( + ReportActor.member(8L)); + String query = new JPAQuery() + .from(QWeatherReport.weatherReport) + .where(predicate) + .toString(); + + assertThat(query) + .contains("where not exists") + .contains("actorBlock.blockerType = ?1 and actorBlock.blockerKey = ?2") + .contains("actorBlock.blockedType = weatherReport.authorType") + .contains("or actorBlock.blockerType = weatherReport.authorType") + .contains("actorBlock.blockedType = ?5 and actorBlock.blockedKey = ?6"); + } + + @Test + void anonymousViewerHasNoMemberBlockRelation() { + assertThat(repository.withoutBlockRelation( + ReportActor.anonymous("anonymous-viewer"))) + .isNull(); + } +}