201 lines
7.3 KiB
Java
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));
|
|
}
|
|
}
|