Files
vpd-permission-poc/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java
2026-06-26 13:53:10 +09:00

201 lines
7.3 KiB
Java

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<BearerTokenRecord> findAll() {
return tokenMapper.findAll();
}
public List<TokenContextView> findTokenContextOptions() {
List<BearerTokenRecord> tokens = tokenMapper.findAll();
List<GroupUserView> groupUsers = groupMapper.findGroupUsers();
Map<Long, List<String>> directRolesByUser = directRolesByUser();
Map<Long, List<String>> groupsByUser = groupsByUser(groupUsers);
Map<Long, List<String>> 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<Long, List<String>> directRolesByUser() {
Map<Long, Set<String>> grouped = new LinkedHashMap<>();
for (UserRoleView view : userMapper.findUserRoles()) {
grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).add(view.roleName());
}
return toListMap(grouped);
}
private Map<Long, List<String>> groupsByUser(List<GroupUserView> groupUsers) {
Map<Long, Set<String>> grouped = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
grouped.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>())
.add(view.groupCode() + " / " + view.groupName());
}
return toListMap(grouped);
}
private Map<Long, List<String>> inheritedRolesByUser(List<GroupUserView> groupUsers) {
Map<Long, Set<String>> rolesByGroup = new LinkedHashMap<>();
for (GroupRoleView view : groupMapper.findGroupRoles()) {
rolesByGroup.computeIfAbsent(view.groupId(), key -> new LinkedHashSet<>()).add(view.roleName());
}
Map<Long, Set<String>> rolesByUser = new LinkedHashMap<>();
for (GroupUserView view : groupUsers) {
Set<String> roles = rolesByGroup.getOrDefault(view.groupId(), Set.of());
if (!roles.isEmpty()) {
rolesByUser.computeIfAbsent(view.userId(), key -> new LinkedHashSet<>()).addAll(roles);
}
}
return toListMap(rolesByUser);
}
private Map<Long, List<String>> toListMap(Map<Long, Set<String>> source) {
Map<Long, List<String>> 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));
}
}