diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java index 4cd889b15..7d1ef50cb 100644 --- a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java @@ -134,6 +134,11 @@ public interface UserService { @Blocking void revokeAuthenticationToken(@PathParam("id") long tokenId); + @PUT + @Path("/token/{id}/renew") + @Blocking + void renewAuthenticationToken(@PathParam("id") long tokenId, @RequestBody long expiration); + // this is a simplified copy of org.keycloak.representations.idm.UserRepresentation class UserData { @NotNull diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/AuthenticationToken.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/AuthenticationToken.java index 196f0d22f..a89eab6d6 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/AuthenticationToken.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/AuthenticationToken.java @@ -21,7 +21,8 @@ @Entity(name = "userinfo_token") public class AuthenticationToken extends PanacheEntityBase { - public static long DEFAULT_EXPIRATION_DAYS = 400; + // locked authentication tokens are not listed and can't be renewed either + public static long DEFAULT_EXPIRATION_DAYS = 400, LOCKED_EXPIRATION_DAYS = 7; @Id @SequenceGenerator( @@ -42,7 +43,7 @@ public class AuthenticationToken extends PanacheEntityBase { private final String name; @Column(name = "date_expired") - private final LocalDate dateExpired; + private LocalDate dateExpired; private boolean revoked; public AuthenticationToken() { @@ -68,6 +69,10 @@ public String getToken() { return token.toString(); } + public boolean isLocked() { + return dateExpired.plusDays(LOCKED_EXPIRATION_DAYS).isBefore(LocalDate.now()); + } + public boolean isExpired() { return LocalDate.now().isAfter(dateExpired); } @@ -88,6 +93,13 @@ public void revoke() { revoked = true; } + public void renew(long days) { + if (isLocked()) { + throw new IllegalStateException("Token is expired and cannot be renewed"); + } + dateExpired = LocalDate.now().plusDays(days); + } + @Override public boolean equals(Object o) { if (this == o) { return true; diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java index 36f88ade4..f65982ffc 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java @@ -15,6 +15,7 @@ import jakarta.transaction.Transactional; import java.security.SecureRandom; +import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -161,7 +162,7 @@ public class UserServiceImpl implements UserService { throw ServiceException.badRequest(format("User {0} is not machine account of team {1}", username, team)); } String newPassword = new SecureRandom().ints(RANDOM_PASSWORD_LENGTH, '0', 'z' + 1).collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString(); - UserInfo.findByIdOptional(username).orElseThrow(() -> ServiceException.notFound(format("User with username {0} not found", username))).setPassword(newPassword); + UserInfo.findByIdOptional(username).orElseThrow(() -> ServiceException.notFound(format("Username {0} not found", username))).setPassword(newPassword); Log.infov("{0} reset password of user {1}", identity.getPrincipal().getName(), username); return newPassword; } @@ -254,24 +255,38 @@ void removeLocalUser(String username) { authToken.persist(); userInfo.persist(); - Log.infov("User {0} created authentication token {1} valid for {2} days", identity.getPrincipal().getName(), tokenRequest.name, tokenRequest.expiration); + Log.infov("{0} created authentication token {1} valid until {2}", identity.getPrincipal().getName(), tokenRequest.name, LocalDate.now().plusDays(tokenRequest.expiration)); return authToken.getToken(); } @Transactional @WithRoles(extras = Roles.HORREUM_SYSTEM) @Override public List authenticationTokens() { - return currentUser().authenticationTokens.stream().map(AuthenticationToken::toHorreumAuthenticationToken).toList(); + return currentUser().authenticationTokens.stream() + .filter(t -> !t.isLocked()) + .sorted((a, b) -> (int) (a.daysToExpiration() - b.daysToExpiration())) + .map(AuthenticationToken::toHorreumAuthenticationToken) + .toList(); } @Transactional @WithRoles(addUsername = true) @Override public void revokeAuthenticationToken(long tokenId) { - AuthenticationToken.findByIdOptional(tokenId).orElseThrow(() -> ServiceException.notFound(format("Token with id {0} not found", tokenId))).revoke(); + AuthenticationToken token = AuthenticationToken.findByIdOptional(tokenId).orElseThrow(() -> ServiceException.notFound(format("Token with id {0} not found", tokenId))); + token.revoke(); + Log.infov("{0} revoked authentication token {1}", identity.getPrincipal().getName(), token.getName()); + } + + @Transactional + @WithRoles(addUsername = true) + @Override public void renewAuthenticationToken(long tokenId, long expiration) { + AuthenticationToken token = AuthenticationToken.findByIdOptional(tokenId).orElseThrow(() -> ServiceException.notFound(format("Token with id {0} not found", tokenId))); + token.renew(expiration); + Log.infov("{0} renewed authentication token {1} until {2}", identity.getPrincipal().getName(), token.getName(), LocalDate.now().plusDays(expiration)); } private UserInfo currentUser() { - return UserInfo.findByIdOptional(identity.getPrincipal().getName()).orElseThrow(() -> ServiceException.notFound(format("User with username {0} not found", identity.getPrincipal().getName()))); + return UserInfo.findByIdOptional(identity.getPrincipal().getName()).orElseThrow(() -> ServiceException.notFound(format("Username {0} not found", identity.getPrincipal().getName()))); } // --- // diff --git a/horreum-web/src/domain/user/AuthentcationTokens.tsx b/horreum-web/src/domain/user/AuthentcationTokens.tsx index 0f6c952f8..0c32ca255 100644 --- a/horreum-web/src/domain/user/AuthentcationTokens.tsx +++ b/horreum-web/src/domain/user/AuthentcationTokens.tsx @@ -7,6 +7,7 @@ import { FormGroup, Label, Modal, + NumberInput, TextInput, Tooltip, yyyyMMddFormat, @@ -23,18 +24,19 @@ export default function AuthenticationTokens() { const daysTo = (other: Date) => Math.ceil((other.getTime() - Date.now()) / (24 * 3600 * 1000)) const defaultExpiration = () => yyyyMMddFormat(new Date(Date.now() + 400 * 24 * 3600 * 1000)) // default 400 days - const refreshTokens = () => - userApi.authenticationTokens().then( - users => setAuthenticationTokens(users), - error => alerting.dispatchError(error, "FETCH_AUTHENTICATION_TOKEN", "Failed to fetch authentication tokens for user") - ) - const [authenticationTokens, setAuthenticationTokens] = useState([]) - const [createNewToken, setCreateNewToken] = useState(false) - const [newAuthenticationTokenValue, setNewAuthenticationTokenValue] = useState() + const refreshTokens = () => userApi.authenticationTokens().then( + users => setAuthenticationTokens(users), + error => alerting.dispatchError(error, "FETCH_AUTHENTICATION_TOKEN", "Failed to fetch authentication tokens for user") + ) + const [createNewToken, setCreateNewToken] = useState(false) const [newTokenName, setNewTokenName] = useState() const [newTokenExpiration, setNewTokenExpiration] = useState(defaultExpiration) + const [newAuthenticationTokenValue, setNewAuthenticationTokenValue] = useState() + + const [renewTokenId, setRenewTokenId] = useState() + const [renewTokenExpiration, setRenewTokenExpiration] = useState(90) const rangeValidator = (date: Date) => { const days = daysTo(date); @@ -61,7 +63,10 @@ export default function AuthenticationTokens() { const d = daysTo(token.dateExpired) if (d < 1) { return - } else if (d < 7) { + } else if (d < 2) { + return + } + if (d < 7) { return } else if (d < 30) { return @@ -70,7 +75,7 @@ export default function AuthenticationTokens() { return <> } - const tokenTooltip = (token: HorreumAuthenticationToken) => { + const tokenExpirationTooltip = (token: HorreumAuthenticationToken) => { if (token.isExpired) { return "Token has expired" } else if (token.dateExpired) { @@ -99,17 +104,25 @@ export default function AuthenticationTokens() { - {authenticationTokens.filter(token => daysTo(token.dateExpired ?? new Date()) >= -30).map((token, i) => ( // filter tokens that expired over 30 days - + {authenticationTokens.map((token, i) => ( + {token.name} {tokenStatus(token)} - {token.dateExpired?.toLocaleDateString()} + + <>{token.dateExpired?.toLocaleDateString() || "undefined"} - { + setRenewTokenExpiration(90) + setRenewTokenId(token.id) + } + }, { title: 'Revoke', onClick: () => { @@ -144,12 +157,7 @@ export default function AuthenticationTokens() { }) .then((tokenValue) => { setNewAuthenticationTokenValue(tokenValue) - void alerting.dispatchInfo( - "TOKEN_CREATED", - "Authentication token created", - "Authentication token was successfully created", - 3000 - ) + void alerting.dispatchInfo("TOKEN_CREATED", "Authentication token created", "Authentication token was successfully created", 3000) }) .catch(error => alerting.dispatchError(error, "TOKEN_NOT_CREATED", "Failed to create new authentication token")) .finally(() => setCreateNewToken(false)) @@ -179,11 +187,49 @@ export default function AuthenticationTokens() { setNewAuthenticationTokenValue(undefined)}> {newAuthenticationTokenValue} + setNewAuthenticationTokenValue(undefined)} + actions={[ + , + , + ]}> +
+ + setRenewTokenExpiration(renewTokenExpiration - 1)} + onPlus={() => setRenewTokenExpiration(renewTokenExpiration + 1)} + onChange={(event) => { + const value = (event.target as HTMLInputElement).value; + setRenewTokenExpiration(value === '' ? 0 : +value); + }} + /> + +
+
) }