[Developer] #619 map stakeholder tokens to permission rules
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.stakeholder;
|
||||
|
||||
/**
|
||||
* 업무 사용자 원장과 백오피스 권한 bridge를 결합한 Bearer Token 발급 대상이다.
|
||||
*/
|
||||
public record StakeholderTokenSubject(
|
||||
String stakeholderUserId,
|
||||
String username,
|
||||
String role,
|
||||
String channel,
|
||||
String accessScope,
|
||||
long appUserId
|
||||
) {
|
||||
|
||||
public String displayLabel() {
|
||||
return username + " / " + stakeholderUserId + " / " + role + " / " + channel;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ public record BearerTokenRecord(
|
||||
long keyId,
|
||||
long userId,
|
||||
String username,
|
||||
String stakeholderUserId,
|
||||
String stakeholderRole,
|
||||
String stakeholderChannel,
|
||||
String keyPrefix,
|
||||
String keyHash,
|
||||
LocalDateTime expiresAt,
|
||||
|
||||
@@ -7,6 +7,9 @@ public record TokenContextView(
|
||||
long keyId,
|
||||
long userId,
|
||||
String username,
|
||||
String stakeholderUserId,
|
||||
String stakeholderRole,
|
||||
String stakeholderChannel,
|
||||
String keyPrefix,
|
||||
LocalDateTime expiresAt,
|
||||
LocalDateTime revokedAt,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice.mapper;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface StakeholderMapper {
|
||||
|
||||
List<StakeholderTokenSubject> findTokenSubjects();
|
||||
|
||||
StakeholderTokenSubject findTokenSubject(@Param("stakeholderUserId") String stakeholderUserId);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class BackofficeSchemaService {
|
||||
user_name VARCHAR2(100) NOT NULL UNIQUE,
|
||||
employee_no VARCHAR2(50),
|
||||
dept_code VARCHAR2(50),
|
||||
stakeholder_user_id VARCHAR2(30),
|
||||
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
|
||||
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
|
||||
)
|
||||
@@ -101,6 +102,7 @@ public class BackofficeSchemaService {
|
||||
CREATE TABLE cb_agent_bearer_key (
|
||||
key_id NUMBER PRIMARY KEY,
|
||||
user_id NUMBER NOT NULL,
|
||||
stakeholder_user_id VARCHAR2(30),
|
||||
key_prefix VARCHAR2(30) NOT NULL,
|
||||
key_hash VARCHAR2(128) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
@@ -178,6 +180,10 @@ public class BackofficeSchemaService {
|
||||
"ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)"),
|
||||
new ColumnDefinition("CB_PERMISSION_RULE", "RULE_COLUMN",
|
||||
"ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"),
|
||||
new ColumnDefinition("CB_APP_USER", "STAKEHOLDER_USER_ID",
|
||||
"ALTER TABLE cb_app_user ADD (stakeholder_user_id VARCHAR2(30))"),
|
||||
new ColumnDefinition("CB_AGENT_BEARER_KEY", "STAKEHOLDER_USER_ID",
|
||||
"ALTER TABLE cb_agent_bearer_key ADD (stakeholder_user_id VARCHAR2(30))"),
|
||||
new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION",
|
||||
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"),
|
||||
new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION",
|
||||
|
||||
@@ -8,10 +8,12 @@ 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.stakeholder.StakeholderTokenSubject;
|
||||
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.StakeholderMapper;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
@@ -33,6 +35,7 @@ public class BearerTokenService {
|
||||
private final BearerTokenMapper tokenMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final GroupMapper groupMapper;
|
||||
private final StakeholderMapper stakeholderMapper;
|
||||
private final AuditService auditService;
|
||||
private final TokenGenerator tokenGenerator;
|
||||
private final TokenHasher tokenHasher;
|
||||
@@ -43,6 +46,7 @@ public class BearerTokenService {
|
||||
BearerTokenMapper tokenMapper,
|
||||
UserMapper userMapper,
|
||||
GroupMapper groupMapper,
|
||||
StakeholderMapper stakeholderMapper,
|
||||
AuditService auditService,
|
||||
TokenGenerator tokenGenerator,
|
||||
TokenHasher tokenHasher,
|
||||
@@ -52,6 +56,7 @@ public class BearerTokenService {
|
||||
this.tokenMapper = tokenMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.groupMapper = groupMapper;
|
||||
this.stakeholderMapper = stakeholderMapper;
|
||||
this.auditService = auditService;
|
||||
this.tokenGenerator = tokenGenerator;
|
||||
this.tokenHasher = tokenHasher;
|
||||
@@ -80,6 +85,9 @@ public class BearerTokenService {
|
||||
token.keyId(),
|
||||
token.userId(),
|
||||
token.username(),
|
||||
token.stakeholderUserId(),
|
||||
token.stakeholderRole(),
|
||||
token.stakeholderChannel(),
|
||||
token.keyPrefix(),
|
||||
token.expiresAt(),
|
||||
token.revokedAt(),
|
||||
@@ -114,6 +122,10 @@ public class BearerTokenService {
|
||||
return tokenMapper.findByHash(tokenHasher.sha256(plainToken));
|
||||
}
|
||||
|
||||
public List<StakeholderTokenSubject> findStakeholderTokenSubjects() {
|
||||
return stakeholderMapper.findTokenSubjects();
|
||||
}
|
||||
|
||||
public boolean matches(BearerTokenRecord record, String plainToken) {
|
||||
if (record == null || plainToken == null || plainToken.isBlank()) {
|
||||
return false;
|
||||
@@ -171,19 +183,42 @@ public class BearerTokenService {
|
||||
throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다.");
|
||||
}
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now(clock);
|
||||
if (command.expiresAt() == null || !command.expiresAt().isAfter(now)) {
|
||||
throw new AppException("만료일은 현재 시각 이후여야 합니다.");
|
||||
return issueForUser(user, null, command.expiresAt(), command.description());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IssuedToken issueStakeholderToken(
|
||||
String stakeholderUserId,
|
||||
OffsetDateTime expiresAt,
|
||||
String description
|
||||
) {
|
||||
if (stakeholderUserId == null || stakeholderUserId.isBlank()) {
|
||||
throw new AppException("토큰을 발급할 이해당사자를 선택하세요.");
|
||||
}
|
||||
if (command.expiresAt().isAfter(now.plusDays(properties.token().maxDays()))) {
|
||||
throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다.");
|
||||
StakeholderTokenSubject subject = stakeholderMapper.findTokenSubject(stakeholderUserId.trim());
|
||||
if (subject == null) {
|
||||
throw new AppException("이해당사자 원장과 연결된 활성 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
AppUser bridgeUser = userMapper.findById(subject.appUserId());
|
||||
if (bridgeUser == null || !bridgeUser.active()) {
|
||||
throw new AppException("이해당사자 권한 bridge 사용자가 비활성 상태입니다.");
|
||||
}
|
||||
return issueForUser(bridgeUser, subject.stakeholderUserId(), expiresAt, description);
|
||||
}
|
||||
|
||||
private IssuedToken issueForUser(
|
||||
AppUser user,
|
||||
String stakeholderUserId,
|
||||
OffsetDateTime expiresAt,
|
||||
String description
|
||||
) {
|
||||
validateExpiry(expiresAt);
|
||||
|
||||
String plainToken = tokenGenerator.generate();
|
||||
String prefix = tokenGenerator.prefix(plainToken);
|
||||
String hash = tokenHasher.sha256(plainToken);
|
||||
long keyId = tokenMapper.nextKeyId();
|
||||
LocalDateTime expiresAt = command.expiresAt()
|
||||
LocalDateTime localExpiresAt = expiresAt
|
||||
.atZoneSameInstant(ZoneId.systemDefault())
|
||||
.toLocalDateTime();
|
||||
|
||||
@@ -191,14 +226,28 @@ public class BearerTokenService {
|
||||
keyId,
|
||||
user.userId(),
|
||||
user.username(),
|
||||
stakeholderUserId,
|
||||
null,
|
||||
null,
|
||||
prefix,
|
||||
hash,
|
||||
expiresAt,
|
||||
localExpiresAt,
|
||||
null,
|
||||
command.description()
|
||||
description
|
||||
));
|
||||
auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, "issued"));
|
||||
return new IssuedToken(keyId, prefix, plainToken, command.expiresAt());
|
||||
auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null,
|
||||
stakeholderUserId == null ? "issued" : "stakeholder=" + stakeholderUserId));
|
||||
return new IssuedToken(keyId, prefix, plainToken, expiresAt);
|
||||
}
|
||||
|
||||
private void validateExpiry(OffsetDateTime expiresAt) {
|
||||
OffsetDateTime now = OffsetDateTime.now(clock);
|
||||
if (expiresAt == null || !expiresAt.isAfter(now)) {
|
||||
throw new AppException("만료일은 현재 시각 이후여야 합니다.");
|
||||
}
|
||||
if (expiresAt.isAfter(now.plusDays(properties.token().maxDays()))) {
|
||||
throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -21,7 +21,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class PermissionService {
|
||||
|
||||
private static final Set<String> RULE_TYPES = Set.of(
|
||||
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
|
||||
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG",
|
||||
"STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
|
||||
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
|
||||
"=", "!=", "DEPT", "EMP_NO", "TAG");
|
||||
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -20,12 +18,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
public class TokenController {
|
||||
|
||||
private final BearerTokenService tokenService;
|
||||
private final UserMapper userMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public TokenController(BearerTokenService tokenService, UserMapper userMapper, Clock clock) {
|
||||
public TokenController(BearerTokenService tokenService, Clock clock) {
|
||||
this.tokenService = tokenService;
|
||||
this.userMapper = userMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@@ -36,23 +32,23 @@ public class TokenController {
|
||||
) {
|
||||
model.addAttribute("tokens", tokenService.findAll(includeInactive));
|
||||
model.addAttribute("includeInactive", includeInactive);
|
||||
model.addAttribute("users", userMapper.findAll());
|
||||
model.addAttribute("stakeholders", tokenService.findStakeholderTokenSubjects());
|
||||
model.addAttribute("defaultExpiresAt", defaultExpiresAt());
|
||||
return "tokens";
|
||||
}
|
||||
|
||||
@PostMapping("/tokens")
|
||||
public String issue(
|
||||
@RequestParam long userId,
|
||||
@RequestParam String stakeholderUserId,
|
||||
@RequestParam String expiresAt,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
var issued = tokenService.issueToken(new TokenIssueCommand(
|
||||
userId,
|
||||
var issued = tokenService.issueStakeholderToken(
|
||||
stakeholderUserId,
|
||||
parseBrowserDateTime(expiresAt),
|
||||
description
|
||||
));
|
||||
);
|
||||
redirectAttributes.addFlashAttribute("issued", issued);
|
||||
return "redirect:/tokens";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user