mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Encrypt tokens
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class OAuthTokenCipher {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(OAuthTokenCipher.class);
|
||||
private static final String KEY_ENV = "IMGFLOAT_TOKEN_ENCRYPTION_KEY";
|
||||
private static final String PREVIOUS_KEYS_ENV = "IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS";
|
||||
private static final String PREFIX = "v1:";
|
||||
private static final int IV_LENGTH_BYTES = 12;
|
||||
private static final int TAG_LENGTH_BITS = 128;
|
||||
|
||||
private final SecretKey encryptionKey;
|
||||
private final List<SecretKey> decryptionKeys;
|
||||
|
||||
public OAuthTokenCipher(SecretKey encryptionKey, List<SecretKey> decryptionKeys) {
|
||||
this.encryptionKey = encryptionKey;
|
||||
this.decryptionKeys = List.copyOf(decryptionKeys);
|
||||
}
|
||||
|
||||
public static OAuthTokenCipher fromEnvironment() {
|
||||
String base64Key = System.getenv(KEY_ENV);
|
||||
if (base64Key == null || base64Key.isBlank()) {
|
||||
throw new IllegalStateException(KEY_ENV + " is required to encrypt OAuth tokens");
|
||||
}
|
||||
SecretKey primaryKey = decodeKey(base64Key, KEY_ENV);
|
||||
List<SecretKey> keys = new ArrayList<>();
|
||||
keys.add(primaryKey);
|
||||
|
||||
String previousKeys = System.getenv(PREVIOUS_KEYS_ENV);
|
||||
if (previousKeys != null && !previousKeys.isBlank()) {
|
||||
for (String value : previousKeys.split(",")) {
|
||||
String trimmed = value.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
keys.add(decodeKey(trimmed, PREVIOUS_KEYS_ENV));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new OAuthTokenCipher(primaryKey, keys);
|
||||
}
|
||||
|
||||
public String encrypt(String plaintext) {
|
||||
if (plaintext == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] iv = new byte[IV_LENGTH_BYTES];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] payload = new byte[iv.length + ciphertext.length];
|
||||
System.arraycopy(iv, 0, payload, 0, iv.length);
|
||||
System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length);
|
||||
return PREFIX + Base64.getEncoder().encodeToString(payload);
|
||||
} catch (GeneralSecurityException ex) {
|
||||
throw new IllegalStateException("Unable to encrypt OAuth token", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (!value.startsWith(PREFIX)) {
|
||||
return value;
|
||||
}
|
||||
byte[] payload = Base64.getDecoder().decode(value.substring(PREFIX.length()));
|
||||
if (payload.length <= IV_LENGTH_BYTES) {
|
||||
throw new IllegalStateException("Invalid encrypted OAuth token payload");
|
||||
}
|
||||
byte[] iv = new byte[IV_LENGTH_BYTES];
|
||||
byte[] ciphertext = new byte[payload.length - IV_LENGTH_BYTES];
|
||||
System.arraycopy(payload, 0, iv, 0, IV_LENGTH_BYTES);
|
||||
System.arraycopy(payload, IV_LENGTH_BYTES, ciphertext, 0, ciphertext.length);
|
||||
for (SecretKey key : decryptionKeys) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (GeneralSecurityException ex) {
|
||||
LOG.debug("Failed to decrypt OAuth token with a configured key", ex);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Unable to decrypt OAuth token with configured keys");
|
||||
}
|
||||
|
||||
public boolean isEncrypted(String value) {
|
||||
return value != null && value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
private static SecretKey decodeKey(String base64Key, String source) {
|
||||
byte[] decoded = Base64.getDecoder().decode(base64Key);
|
||||
if (decoded.length != 32) {
|
||||
throw new IllegalArgumentException(
|
||||
source + " must be a base64-encoded 256-bit (32 byte) key"
|
||||
);
|
||||
}
|
||||
return new SecretKeySpec(decoded, "AES");
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,23 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
private final JdbcOperations jdbcOperations;
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
||||
private final OAuthTokenCipher tokenCipher;
|
||||
|
||||
public SQLiteOAuth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
this(jdbcOperations, clientRegistrationRepository, OAuthTokenCipher.fromEnvironment());
|
||||
}
|
||||
|
||||
SQLiteOAuth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository,
|
||||
OAuthTokenCipher tokenCipher
|
||||
) {
|
||||
this.jdbcOperations = jdbcOperations;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.tokenCipher = tokenCipher;
|
||||
this.rowMapper = (rs, rowNum) -> {
|
||||
String registrationId = rs.getString("client_registration_id");
|
||||
String principalName = rs.getString("principal_name");
|
||||
@@ -41,18 +51,41 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
return null;
|
||||
}
|
||||
|
||||
String accessTokenValue = decryptToken(
|
||||
registrationId,
|
||||
principalName,
|
||||
rs.getString("access_token_value"),
|
||||
"access"
|
||||
);
|
||||
if (accessTokenValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
rs.getString("access_token_value"),
|
||||
accessTokenValue,
|
||||
toInstant(rs.getObject("access_token_issued_at")),
|
||||
toInstant(rs.getObject("access_token_expires_at")),
|
||||
scopesFrom(rs.getString("access_token_scopes"))
|
||||
);
|
||||
|
||||
Object refreshValue = rs.getObject("refresh_token_value");
|
||||
OAuth2RefreshToken refreshToken = refreshValue == null
|
||||
? null
|
||||
: new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at")));
|
||||
OAuth2RefreshToken refreshToken = null;
|
||||
if (refreshValue != null) {
|
||||
String refreshTokenValue = decryptToken(
|
||||
registrationId,
|
||||
principalName,
|
||||
refreshValue.toString(),
|
||||
"refresh"
|
||||
);
|
||||
if (refreshTokenValue == null) {
|
||||
return null;
|
||||
}
|
||||
refreshToken = new OAuth2RefreshToken(
|
||||
refreshTokenValue,
|
||||
toInstant(rs.getObject("refresh_token_issued_at"))
|
||||
);
|
||||
}
|
||||
|
||||
return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken);
|
||||
};
|
||||
@@ -104,7 +137,7 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
|
||||
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
|
||||
if (refreshToken != null) {
|
||||
preparedStatement.setString(7, refreshToken.getTokenValue());
|
||||
preparedStatement.setString(7, tokenCipher.encrypt(refreshToken.getTokenValue()));
|
||||
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
|
||||
} else {
|
||||
preparedStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
@@ -134,10 +167,34 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
|
||||
private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token)
|
||||
throws java.sql.SQLException {
|
||||
ps.setString(startIndex, token.getTokenValue());
|
||||
ps.setString(startIndex, tokenCipher.encrypt(token.getTokenValue()));
|
||||
ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT);
|
||||
}
|
||||
|
||||
private String decryptToken(
|
||||
String registrationId,
|
||||
String principalName,
|
||||
String tokenValue,
|
||||
String tokenType
|
||||
) {
|
||||
if (tokenValue == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return tokenCipher.decrypt(tokenValue);
|
||||
} catch (RuntimeException ex) {
|
||||
LOG.warn(
|
||||
"Failed to decrypt {} token for registration ID '{}' and principal '{}'; clearing stored tokens",
|
||||
tokenType,
|
||||
registrationId,
|
||||
principalName,
|
||||
ex
|
||||
);
|
||||
removeAuthorizedClient(registrationId, principalName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Instant toInstant(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user