Skip to content

Commit

Permalink
Allow renew of authentication tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
barreiro committed Aug 30, 2024
1 parent f2979b8 commit dd6925e
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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() {
Expand All @@ -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);
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.<UserInfo>findByIdOptional(username).orElseThrow(() -> ServiceException.notFound(format("User with username {0} not found", username))).setPassword(newPassword);
UserInfo.<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;
}
Expand Down Expand Up @@ -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<HorreumAuthenticationToken> 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.<AuthenticationToken>findByIdOptional(tokenId).orElseThrow(() -> ServiceException.notFound(format("Token with id {0} not found", tokenId))).revoke();
AuthenticationToken token = AuthenticationToken.<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.<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.<UserInfo>findByIdOptional(identity.getPrincipal().getName()).orElseThrow(() -> ServiceException.notFound(format("User with username {0} not found", identity.getPrincipal().getName())));
return UserInfo.<UserInfo>findByIdOptional(identity.getPrincipal().getName()).orElseThrow(() -> ServiceException.notFound(format("Username {0} not found", identity.getPrincipal().getName())));
}

// --- //
Expand Down
90 changes: 68 additions & 22 deletions horreum-web/src/domain/user/AuthentcationTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
FormGroup,
Label,
Modal,
NumberInput,
TextInput,
Tooltip,
yyyyMMddFormat,
Expand All @@ -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<HorreumAuthenticationToken[]>([])
const [createNewToken, setCreateNewToken] = useState(false)
const [newAuthenticationTokenValue, setNewAuthenticationTokenValue] = useState<string>()
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<string>()
const [newTokenExpiration, setNewTokenExpiration] = useState<string>(defaultExpiration)
const [newAuthenticationTokenValue, setNewAuthenticationTokenValue] = useState<string>()

const [renewTokenId, setRenewTokenId] = useState<number>()
const [renewTokenExpiration, setRenewTokenExpiration] = useState<number>(90)

const rangeValidator = (date: Date) => {
const days = daysTo(date);
Expand All @@ -61,7 +63,10 @@ export default function AuthenticationTokens() {
const d = daysTo(token.dateExpired)
if (d < 1) {
return <Label color="orange">Expires TODAY</Label>
} else if (d < 7) {
} else if (d < 2) {
return <Label color="orange">Expires TOMORROW</Label>
}
if (d < 7) {
return <Label color="gold">Expires in less than a week</Label>
} else if (d < 30) {
return <Label color="green">Expires in less than a month</Label>
Expand All @@ -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) {
Expand Down Expand Up @@ -99,17 +104,25 @@ export default function AuthenticationTokens() {
</Tr>
</Thead>
<Tbody>
{authenticationTokens.filter(token => daysTo(token.dateExpired ?? new Date()) >= -30).map((token, i) => ( // filter tokens that expired over 30 days
<Tr key={"token-" + i}>
{authenticationTokens.map((token, i) => (
<Tr key={`token-${i}`}>
<Td dataLabel="name">{token.name}</Td>
<Td dataLabel="status">{tokenStatus(token)}</Td>
<Td dataLabel="expiration">
<Tooltip content={tokenTooltip(token)}><span
id={"authentication-token-expired-" + i}>{token.dateExpired?.toLocaleDateString()}</span>
<Tooltip content={tokenExpirationTooltip(token)}>
<>{token.dateExpired?.toLocaleDateString() || "undefined"}</>
</Tooltip>
</Td>
<Td isActionCell>
<ActionsColumn items={[
<ActionsColumn items={token.isRevoked ? [] : [
{
title: 'Renew',
isDisabled: !(token.dateExpired && daysTo(token.dateExpired) < 7), // only allow to renew in last 7 days
onClick: () => {
setRenewTokenExpiration(90)
setRenewTokenId(token.id)
}
},
{
title: 'Revoke',
onClick: () => {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -179,11 +187,49 @@ export default function AuthenticationTokens() {
<Modal
isOpen={newAuthenticationTokenValue != undefined}
title={`New token: ${newTokenName}`}
aria-label="New authentication token"
aria-label="new-authentication-token"
variant="small"
onClose={() => setNewAuthenticationTokenValue(undefined)}>
<ClipboardCopy isReadOnly>{newAuthenticationTokenValue}</ClipboardCopy>
</Modal>
<Modal
isOpen={renewTokenId != undefined}
title={`Renew token`}
aria-label="renew-authentication-token"
variant="small"
onClose={() => setNewAuthenticationTokenValue(undefined)}
actions={[
<Button
onClick={() => {
if (renewTokenId) {
userApi.renewAuthenticationToken(renewTokenId, renewTokenExpiration)
.then(() => void alerting.dispatchInfo("TOKEN_RENEWED", "Authentication token renewed", "Authentication token was successfully renewed", 3000))
.catch(error => alerting.dispatchError(error, "TOKEN_NOT_RENEWED", "Failed to renew authentication token"))
.finally(() => setRenewTokenId(undefined))
.then(() => void refreshTokens())
}
}}
>
Renew
</Button>,
<Button variant="secondary" onClick={() => setRenewTokenId(undefined)}>Cancel</Button>,
]}>
<Form isHorizontal>
<FormGroup isRequired label="Days to expiration" fieldId="renew-authentication-token-expiration">
<NumberInput
value={renewTokenExpiration}
min={0}
max={100}
onMinus={() => setRenewTokenExpiration(renewTokenExpiration - 1)}
onPlus={() => setRenewTokenExpiration(renewTokenExpiration + 1)}
onChange={(event) => {
const value = (event.target as HTMLInputElement).value;
setRenewTokenExpiration(value === '' ? 0 : +value);
}}
/>
</FormGroup>
</Form>
</Modal>
</>
)
}

0 comments on commit dd6925e

Please sign in to comment.