package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView; import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken; import com.cloudhandson.vpdbackoffice.domain.token.TokenContextView; import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand; import com.cloudhandson.vpdbackoffice.domain.user.AppUser; import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView; import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper; import com.cloudhandson.vpdbackoffice.mapper.GroupMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.time.Clock; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class BearerTokenService { private final BearerTokenMapper tokenMapper; private final UserMapper userMapper; private final GroupMapper groupMapper; private final AuditService auditService; private final TokenGenerator tokenGenerator; private final TokenHasher tokenHasher; private final BackofficeProperties properties; private final Clock clock; public BearerTokenService( BearerTokenMapper tokenMapper, UserMapper userMapper, GroupMapper groupMapper, AuditService auditService, TokenGenerator tokenGenerator, TokenHasher tokenHasher, BackofficeProperties properties, Clock clock ) { this.tokenMapper = tokenMapper; this.userMapper = userMapper; this.groupMapper = groupMapper; this.auditService = auditService; this.tokenGenerator = tokenGenerator; this.tokenHasher = tokenHasher; this.properties = properties; this.clock = clock; } public List findAll() { return tokenMapper.findAll(); } public List findTokenContextOptions() { List tokens = tokenMapper.findAll(); List groupUsers = groupMapper.findGroupUsers(); Map> directRolesByUser = directRolesByUser(); Map> groupsByUser = groupsByUser(groupUsers); Map> inheritedRolesByUser = inheritedRolesByUser(groupUsers); LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault())); return tokens.stream() .map(token -> new TokenContextView( token.keyId(), token.userId(), token.username(), token.keyPrefix(), token.expiresAt(), token.revokedAt(), token.description(), token.active(now), directRolesByUser.getOrDefault(token.userId(), List.of()), groupsByUser.getOrDefault(token.userId(), List.of()), inheritedRolesByUser.getOrDefault(token.userId(), List.of()) )) .toList(); } public BearerTokenRecord findById(long keyId) { return tokenMapper.findById(keyId); } public BearerTokenRecord findByPlainToken(String plainToken) { if (plainToken == null || plainToken.isBlank()) { return null; } return tokenMapper.findByHash(tokenHasher.sha256(plainToken)); } public boolean matches(BearerTokenRecord record, String plainToken) { if (record == null || plainToken == null || plainToken.isBlank()) { return false; } String hash = tokenHasher.sha256(plainToken); return hash.equalsIgnoreCase(record.keyHash()); } private Map> directRolesByUser() { Map> grouped = new LinkedHashMap<>(); for (UserRoleView view : userMapper.findUserRoles()) { grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.roleName()); } return toListMap(grouped); } private Map> groupsByUser(List groupUsers) { Map> grouped = new LinkedHashMap<>(); for (GroupUserView view : groupUsers) { grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()) .add(view.groupCode() + " / " + view.groupName()); } return toListMap(grouped); } private Map> inheritedRolesByUser(List groupUsers) { Map> rolesByGroup = new LinkedHashMap<>(); for (GroupRoleView view : groupMapper.findGroupRoles()) { rolesByGroup.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.roleName()); } Map> rolesByUser = new LinkedHashMap<>(); for (GroupUserView view : groupUsers) { Set roles = rolesByGroup.getOrDefault(view.groupId(), Set.of()); if (!roles.isEmpty()) { rolesByUser.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).addAll(roles); } } return toListMap(rolesByUser); } private Map> toListMap(Map> source) { Map> result = new LinkedHashMap<>(); source.forEach((key, value) -> result.put(key, new ArrayList<>(value))); return result; } @Transactional public IssuedToken issueToken(TokenIssueCommand command) { AppUser user = userMapper.findById(command.userId()); if (user == null) { throw new AppException("사용자를 찾을 수 없습니다."); } if (!user.active()) { throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다."); } OffsetDateTime now = OffsetDateTime.now(clock); if (command.expiresAt() == null || !command.expiresAt().isAfter(now)) { throw new AppException("만료일은 현재 시각 이후여야 합니다."); } if (command.expiresAt().isAfter(now.plusDays(properties.token().maxDays()))) { throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다."); } String plainToken = tokenGenerator.generate(); String prefix = tokenGenerator.prefix(plainToken); String hash = tokenHasher.sha256(plainToken); long keyId = tokenMapper.nextKeyId(); LocalDateTime expiresAt = command.expiresAt() .atZoneSameInstant(ZoneId.systemDefault()) .toLocalDateTime(); tokenMapper.insertToken(new BearerTokenRecord( keyId, user.userId(), user.username(), prefix, hash, expiresAt, null, command.description() )); auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, "issued")); return new IssuedToken(keyId, prefix, plainToken, command.expiresAt()); } @Transactional public void revokeToken(long keyId, String reason) { int updated = tokenMapper.revokeToken( keyId, LocalDateTime.now(clock.withZone(ZoneId.systemDefault())), reason ); if (updated == 0) { throw new AppException("회수할 활성 토큰을 찾을 수 없습니다."); } auditService.record(new AuditEvent("TOKEN_REVOKED", keyId, null, "SUCCESS", null, null, reason)); } }