La semaine dernière, j’ai écrit sur le fait de mettre la bonne fonctionnalité au bon endroit. J’ai utilisé la limitation de débit comme exemple, en la déplaçant d’une bibliothèque à l’intérieur de l’application vers la passerelle API. Aujourd’hui, je vais utiliser un autre exemple : l’authentification et l’autorisation.
Sécuriser une application Spring Boot
Je continuerai à utiliser Spring Boot dans ce qui suit car je le connais bien. L’application Spring Boot propose un point de terminaison REST pour vérifier les salaires des employés.
Le cas d’utilisation spécifique est tiré du site Open Policy Agent (plus tard):
Créez une politique qui permet aux utilisateurs de demander leur propre salaire ainsi que le salaire de leurs subordonnés directs.
Nous avons besoin d’un moyen de :
- Authentifiez une requête HTTP comme provenant d’un utilisateur connu.
- Vérifiez si l’utilisateur a accès aux données salariales.
Dans tous les autres cas, renvoyez un 401
.
Je vais passer un jeton d’authentification dans la demande pour simplifier les choses. Je ne m’appuierai pas sur un backend d’authentification/autorisation dédié, tel que Keycloak, mais cela devrait être une approche similaire si vous le faites.
Pour activer Spring Security sur l’application, nous devons ajouter Spring Boot Security Starter.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Nous devons également permettre à Spring Security d’opérer sa magie :
@SpringBootApplication
@EnableWebSecurity
class SecureBootApplication
Une fois ces deux étapes en place, nous pouvons commencer à sécuriser l’application conformément à l’exigence ci-dessus :
internal fun security() = beans { //1
bean {
val http = ref<HttpSecurity>()
http {
authorizeRequests {
authorize("/finance/salary/**", authenticated) //2
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(
TokenAuthenticationFilter(ref()) //3
)
httpBasic { disable() }
csrf { disable() }
logout { disable() }
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
}
http.build()
}
bean { TokenAuthenticationManager(ref(), ref()) } //4
}
- Utilisez le Kotlin Beans DSL – parce que je peux.
- N’autorisez l’accès au point de terminaison qu’aux utilisateurs authentifiés.
- Ajoutez un filtre dans la chaîne de filtrage pour remplacer l’authentification standard.
- Ajoutez un gestionnaire d’authentification personnalisé.
Les requêtes ressemblent à ceci :
curl -H 'Authorization: xyz' localhost:9080/finance/salary/bob
Le filtre extrait de la requête les données nécessaires utilisées pour décider d’autoriser ou non la requête :
internal class TokenAuthenticationFilter(authManager: AuthenticationManager) :
AbstractAuthenticationProcessingFilter("/finance/salary/**", authManager) {
override fun attemptAuthentication(req: HttpServletRequest, resp: HttpServletResponse): Authentication {
val header = req.getHeader("Authorization") //1
val path = req.servletPath.split("https://dzone.com/") //2
val token = KeyToken(header, path) //3
return authenticationManager.authenticate(token) //4
}
// override fun successfulAuthentication(
}
- Obtenez le jeton d’authentification.
- Obtenez le chemin.
- Enveloppez-le sous une structure dédiée.
- Essayez d’authentifier le jeton.
À son tour, le gestionnaire essaie d’authentifier le jeton :
internal class TokenAuthenticationManager(
private val accountRepo: AccountRepository,
private val employeeRepo: EmployeeRepository
) : AuthenticationManager {
override fun authenticate(authentication: Authentication): Authentication {
val token = authentication.credentials as String? ?: //1
throw BadCredentialsException("No token passed")
val account = accountRepo.findByPassword(token).orElse(null) ?: //2
throw BadCredentialsException("Invalid token")
val path = authentication.details as List<String>
val accountId = account.id
val segment = path.last()
if (segment == accountId) return authentication.withPrincipal(accountId) //3
val employee = employeeRepo.findById(segment).orElse(null) //4
val managerUserName = employee?.manager?.userName
if (managerUserName != null && managerUserName == accountId) //5
return authentication.withPrincipal(accountId) //5
throw InsufficientAuthenticationException("Incorrect token") //6
}
}
- Obtenez le jeton d’autorisation transmis par le filtre.
- Essayez de trouver le compte qui a ce jeton. Par souci de simplicité, le jeton est stocké en texte brut sans hachage.
- Si le compte tente d’accéder à ses données, autorisez-le.
- Sinon, nous devons charger la hiérarchie à partir d’un autre dépôt.
- Si le compte tente d’accéder aux données d’un employé qu’il gère, autorisez-le.
- Sinon, niez-le.
L’ensemble du flux peut être résumé comme suit :
Maintenant, nous pouvons essayer quelques requêtes.
curl -H 'Authorization: bob' localhost:9080/finance/salary/bob
bob
demande son propre salaire, et ça marche.
curl -H 'Authorization: bob' localhost:9080/finance/salary/alice
bob
demande le salaire d’un de ses subordonnés, et ça marche aussi.
curl -H 'Authorization: bob' localhost:9080/finance/salary/alice
alice
demande le salaire de son manager, ce qui n’est pas autorisé.
Le code ci-dessus fonctionne parfaitement mais a un gros problème : il n’y a aucun moyen d’auditer la logique. Il faut connaître Kotlin et le fonctionnement de Spring Security pour s’assurer que la mise en œuvre est correcte.
Présentation d’Open Policy Agent
Open Policy Agent, ou OPA en abrégé, se décrit comme un « contrôle basé sur des politiques pour les environnements natifs du cloud ».
Arrêtez d’utiliser un langage de politique, un modèle de politique et une API de politique différents pour chaque produit et service que vous utilisez. Utilisez OPA pour un ensemble d’outils et un cadre unifiés pour la politique sur la pile cloud native.
Que ce soit pour un service ou pour tous vos services, utilisez OPA pour dissocier la politique du code du service afin de pouvoir publier, analyser et réviser les politiques (que les équipes de sécurité et de conformité adorent) sans sacrifier la disponibilité ou les performances.
– Site Web de l’OPA
En bref, OPA permet d’écrire des politiques et propose une CLI et une application démon pour les évaluer.
Vous écrivez des politiques dans un langage interprété spécifique nommé Rego, et je dois admettre que ce n’est pas amusant. Quoi qu’il en soit, voici notre politique ci-dessus écrite en texte « clair »:
package ch.frankel.blog.secureboot
employees := data.hierarchy //1
default allow := false
# Allow users to get their own salaries.
allow {
input.path == ["finance", "salary", input.user] //2
}
# Allow managers to get their subordinates' salaries.
allow {
some username
input.path = ["finance", "salary", username] //3
employees[input.user][_] == username //3
}
- Obtenez la hiérarchie des employés d’une manière ou d’une autre (voir ci-dessous).
- Si le compte demande leur salaire, autorisez l’accès.
- Si le compte demande le salaire d’un subordonné, autorisez l’accès.
J’ai utilisé deux variables dans l’extrait ci-dessus : input
et data
. input
est la charge utile que l’application envoie à OPA. Il doit être au format JSON et se présenter sous la forme suivante :
{
"path": [
"finance",
"salary",
"alice"
],
"user": "bob"
}
Plus de bonté d’agent de politique ouverte
Cependant, OPA ne peut pas décider seul de l’entrée, car il ne connaît pas la hiérarchie de l’employé. Une approche consisterait à charger les données de la hiérarchie sur l’application et à les envoyer à OPA. Une approche plus robuste consiste à laisser l’OPA accéder aux données externes pour séparer proprement les responsabilités. OPA offre de nombreuses options pour y parvenir. Ici, je fais semblant d’extraire des données de la Employee
base de données, regroupez-le avec le fichier de stratégie, servez le bundle via HTTP et configurez OPA pour le charger à intervalles réguliers.
Notez que vous ne devez pas utiliser Apache APISIX uniquement pour servir des fichiers statiques. Mais comme je l’utiliserai dans la prochaine évolution de mon architecture, je souhaite éviter d’avoir un serveur HTTP séparé pour simplifier le système.
Maintenant que nous avons déplacé la logique de décision vers OPA, nous pouvons remplacer notre code par une requête au service OPA. La nouvelle version du gestionnaire d’authentification est :
internal class OpaAuthenticationManager(
private val accountRepo: AccountRepository,
private val opaWebClient: WebClient
) : AuthenticationManager {
override fun authenticate(authentication: Authentication): Authentication {
val token = authentication.credentials as String? ?: //1
throw BadCredentialsException("No token passed")
val account = accountRepo.findByPassword(token).orElse(null) ?: //1
throw BadCredentialsException("Invalid token")
val path = authentication.details as List<String>
val decision = opaWebClient.post() //2
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(OpaInput(DataInput(account.id, path))) //3
.exchangeToMono { it.bodyToMono(DecisionOutput::class.java) } //4
.block() ?: DecisionOutput(ResultOutput(false)) //5
if (decision.result.allow) return authentication.withPrincipal(account.id) //6
else throw InsufficientAuthenticationException("OPA disallow") //6
}
}
- Gardez l’initiale authentification logique.
- Remplacez l’autorisation par un appel au service OPA.
- Sérialisez les données pour qu’elles soient conformes à l’entrée JSON attendue par la règle OPA.
- Désérialisez le résultat.
- Si quelque chose ne va pas,…