# Security
Mit Spring Security (opens new window) lassen sich Authentifizierung und Autorisierung abbilden.
Authentifizierung bedeutet, dass sich ein Benutzer beim Server authentisiert, also sich mit Zugangsdaten anmeldet. Dafür können verschiedene Methoden benutzt werden. Im einfachsten Fall ist dies Basic-Auth mit Benutzername und Passwort, es kann aber auch ein Bearer Token geprüft werden. Ebenso wird SAML und die Anbindung von OAuth unterstützt.
Autorisierung meint das Erteilen von Zugriffsrechten, also auf welche Ressourcen ein Benutzer Zugriff hat und auf welche nicht. Hier gibt es keine harten Vorgaben von Spring. Einen Grundbaustein liefert Spring aber doch, die Authority.
Davon abgesehen bringt Spring Security noch weitere Sicherheitsfeatures wie bestimmte Response-Header (opens new window) (z. B. HSTS, X-Frame-Options, X-XSS-Protection), CORS und Schutz vor Cross-Site Request Forgery (CSRF) mit.
# Dependencies
Beim Spring Initializr muss Spring Security gewählt werden. Alternativ müssen folgende Dependencies im Buildscript hinzugefügt werden:
implementation("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.security:spring-security-test")
# Konfiguration
Ein großer Teil von Spring Security wird mittels eines Beans vom Typ SecurityWebFilterChain konfiguriert.
import org.springframework.security.config.web.server.invoke
@Configuration
class SecurityConfig {
@Bean
fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http {
// config...
}
}
Innerhalb von http { } können verschiedene Dinge eingestellt werden. So können beispielsweise standardmäßig aktivierte Features deaktiviert werden.
formLogin {
disable()
}
csrf {
disable()
}
# Pfadzugriffe
Es können auch Pfade mit entsprechenden Zugriffsregeln versehen werden. Dabei können Platzhalter (opens new window) verwendet werden.
authorizeExchange {
authorize("/api/administration/?**", hasRole("ADMIN"))
authorize("/api/ui", permitAll)
authorize("/api/webjars/**", permitAll)
authorize("/api/openapi/**", permitAll)
authorize("/api/**", authenticated)
authorize(anyExchange, permitAll)
}
WARNING
Da die Reihenfolge der Regeln entscheidend ist, müssen spezifischere Regeln weiter oben stehen, da die Zugriffe sonst von unspezifischeren Regeln abgefangen werden.
# Authority
Spring hält den authentifizierten Nutzer als Authentication vor. Darin gibt es u. a. die Methode getAuthorities() welche eine Liste von GrantedAuthority zurückgibt. So eine Authority stellt sich immer als String dar, beispielsweise SHUTDOWN_ALLOWED oder CAN_BE_ROOT.
Eine Konvention von Spring Security, die darauf aufbaut, ist eine Rolle. Das ist nichts weiter, als eine Authority mit dem Präfix ROLE_ - Spring wandelt intern Rollen zu Authorities mit diesem Präfix um. Der Ausdruck hasRole('ADMIN') entspricht also genau dem Ausdruck hasAuthority('ROLE_ADMIN').
Man kann die Authority-Namen frei vergeben, sollte aber aufpassen, dass diese sich nicht mit Authorities überschneiden, die eine andere Bibliothek vielleicht mitbringt. Beispielsweise könnte es im Zusammenhang mit OAuth Authorities der Form SCOPE_profile:read geben.
# Basic Auth
Ist ein Benutzer bei einem Aufruf nicht berechtigt, löst Spring Security einen Auth-Dialog im Browser aus. Möchte man stattdessen nur ein 401 senden, lässt sich das überschreiben.
httpBasic {
authenticationEntryPoint = HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)
}
Falls nach erfolgreichem Basic Auth automatisch eine Session erzeugt werden soll, kann ein SecurityContextRepository angegeben werden. Die Session-ID wird dem Client dabei in einem SESSION Cookie mitgeteilt, welches er wieder mitschicken muss. Das von Spring Security mitgelieferte WebSessionServerSecurityContextRepository legt Sessions in einem InMemoryWebSessionStore an, der maximal 10.000 aktive Sessions vorhalten kann. Sessions werden nach 30 Minuten Inaktivität gelöscht, es muss also in diesem Zeitraum eine Aktivität (ein Request mit entsprechendem SESSION Cookie) erfolgen. Da die Sessions nur im RAM vorgehalten werden, sind sie nach einem Restart natürlich weg.
TIP
Falls eine Session-ID und valide Basic-Auth Credentials mitgeschickt werden, wird eine neue Session-ID erzeugt und diese dem Client mitgeteilt. Das dient zur Verhinderung von Session-Fixation Angriffen.
Um Sessions persistent zu speichern (oder auch zentral, für geclusterte Anwendungen) und einzustellen, ob die Session-ID in einem Cookie oder einem Header transportiert wird, ist Spring Session nötig.
# User
Eine User-Entity legt man selbst nach seinen Bedürfnissen an. Hier ist ein Minimalbeispiel.
@Entity
class User(
@Column(unique = true)
var username: String,
var password: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
var enabled: Boolean = true
}
interface UserRepository : JpaRepository<User, Long> {
fun getByUsername(username: String): User?
}
Das Passwort wird verschlüsselt gespeichert, es steht also nicht im Klartext in der Datenbank. Das Standard-Hashverfahren beim Schreiben ist BCrypt, Spring Security unterstützt beim Lesen aber auch andere Verfahren. Der standardmäßig verwendete DelegatingPasswordEncoder (opens new window) schreibt dafür Passworteinträge wie z. B. {bcrypt}$2a$10$dXJ3SW6G7P..., stellt also das verwendete Verfahren in geschweiften Klammern dem gehashten Passwort voran. So können auch im Laufe der Zeit Verfahren gewechselt und Passwörter aktualisiert werden.
# UserDetails
Das Dekodieren der Basic Auth Informationen im Request übernimmt Spring automatisch. Man muss lediglich noch einen Service zur Verfügung stellen, der anhand eines Benutzernamens die Details (inkl. gehashtem Passwort) vom User zurückgibt.
Für die Details erstellt man eine eigene data class, die von org.springframework.security.core.userdetails.UserDetails abgeleitet ist.
data class AppUserDetail(
private val user: User
) : UserDetails {
override fun getUsername(): String {
return user.username
}
override fun getPassword(): String {
return user.password
}
override fun getAuthorities(): Collection<GrantedAuthority> {
return user.groups.map { group ->
SimpleGrantedAuthority("GROUP_${group.name.toUpperCase()}")
}
}
override fun isEnabled(): Boolean {
return user.enabled
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
}
Wie man sieht können diverse zugriffsrelevante Benutzereigenschaften definiert werden, die von Spring abgefragt werden, beispielsweise ob ein Account gesperrt ist. Außerdem werden hier die erteilten Authorities gesetzt. SimpleGrantedAuthority ist die einfachste Form einer Authority, die nur ihren Name kennt. Man kann aber auch selbst von GrantedAuthority abgeleitete Klassen schreiben.
# ReactiveUserDetailsService
Damit Spring die Passwörter vergleichen kann, muss ein Service anhand eines Benutzernamens die obigen User Details liefern. Dazu erstellt man einen @Service, der ReactiveUserDetailsService implementiert. Es sollte auch gleich ReactiveUserDetailsPasswordService implementiert werden, damit alte Passwort-Hashes zu neuen Passwort-Hashes aktualisiert werden, falls sich das gewählte Hash-Verfahren ändert.
@Service
class UserDetailsService(
private val appUserRepository: AppUserRepository,
) : ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
fun loadByUsername(username: String?) =
Mono.justOrEmpty(username)
.switchIfEmpty(Mono.error(UsernameNotFoundException("username missing")))
.flatMap { Mono.justOrEmpty(appUserRepository.getByUsername(it)) }
.switchIfEmpty(Mono.error(UsernameNotFoundException("username '$username' not found")))
override fun findByUsername(username: String?): Mono<UserDetails> =
loadByUsername(username).map { user ->
AppUserDetail(user)
}
override fun updatePassword(user: UserDetails?, newPassword: String?): Mono<UserDetails> {
if (user == null || newPassword == null) {
return Mono.error(IllegalArgumentException("user and newPassword cannot be null"))
}
return loadByUsername(user.username).map { user ->
user.password = newPassword
AppUserDetail(appUserRepository.save(user))
}
}
}
Mittels der Methode findByUsername fragt Spring nach den Details zu einem Benutzernamen. Hier gibt man eine Instanz der angelegten User Details Klasse zurück, welche ja auch das gehashte Passwort enthält. Dieses vergleicht Spring dann mit dem im Request übergebenem Passwort.
updatePassword wird automatisch aufgerufen, falls das gespeicherte Passwort aktualisiert werden muss. Das neue Passwort wird bereits gehasht übergeben und muss nur noch persistiert werden. Die neuen User Details (mit dem neuen Passwort) müssen dann zurückgegeben werden.
# OAuth / Token
Spring Security unterstützt die Delegation der Anmeldung an externe OAuth2 / OpenID Provider. Dabei müssen bei bekannten Anbietern wie Google nur wenige Informationen (opens new window) angegeben werden. Es können aber auch eigene Provider (opens new window) definiert werden.
Ebenfalls ist es möglich einen Resource Server zu erstellen. Das bedeutet Clients wollen z. B. APIs aufrufen und schicken dafür ihren Token mit. Spring muss diesen Token dann überprüfen, um den Benutzer zu authentifizieren und zu autorisieren. Dazu wird im SecurityWebFilterChain Bean von oben festgelegt, dass die hereinkommenden Tokens keine JWT-Tokens, sondern irgendetwas anderes sind. Es wird ein sogenannter TokenIntrospector festgelegt, der den Token überprüft.
@Configuration
class SecurityConfig(
private val appTokenIntrospector: AppTokenIntrospector
) {
@Bean
fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http {
// ...
oauth2ResourceServer {
opaqueToken {
introspector = appTokenIntrospector
}
}
}
}
Diesen Introspector legt man als Komponente an und überprüft dort den Token. Hier ein Minimalbeispiel. Die Access Tokens sind als Entity definert und beinhalten natürlich auch den zugehörigen User. Das zurückgegebene OAuth2IntrospectionAuthenticatedPrincipal Objekt enthält dann wieder die Authorities.
@Component
class AppTokenIntrospector(
private val userService: UserService,
private val accessTokenService: AccessTokenService
) : ReactiveOpaqueTokenIntrospector {
override fun introspect(token: String?): Mono<OAuth2AuthenticatedPrincipal> {
if (token == null) {
return Mono.error(OAuth2IntrospectionException("missing token"))
}
val accessToken = accessTokenService.checkToken(token)
?: return Mono.error(OAuth2IntrospectionException("unknown/expired token"))
val user = accessToken.user
return userService.findByUsername(user.username).map {
OAuth2IntrospectionAuthenticatedPrincipal(
it.username,
mapOf(
OAuth2IntrospectionClaimNames.ACTIVE to true
),
it.authorities
)
}
}
}
WARNING
Die Access Tokens dürfen nicht plain in der Datenbank abgelegt werden, da sie ja Passwörtern entsprechen. Es muss also auch für die Tokens ein PasswordEncoder benutzt werden. Der User bekommt das Token auch nur bei der Erstellung zu Gesicht und muss es sich kopieren.
Damit die Zuordnung einfacher wird, kann das dem User angezeigte Token die ID der AccessToken-Entity enthalten, welche beim Prüfen dann wieder geparst wird. So kann der Rest des übergebenen Tokens mit dem gehashten Token in der Datenbank verglichen werden.
Achtung! Keine speziellen Zeichen für das Token verwenden, sonst erkennt Spring das Token im Request nicht. Die RFC (opens new window) listet erlaubte Zeichen.
Zur Sicherheit sollte ein Token zeitlich begrenz werden können. Je nach Anwendung kann dies auch erzwungen werden (beispielsweise maximal 90 Tage).
Damit der User in der Liste seiner Tokens den Überblick behält, sollte zum Token noch eine stark verkürzte Form ("b5...3a") sowie der Erstellzeitpunkt und eine optionale Bemerkung abgespeichert werden.
# Zugriff auf User in APIs
In APIs können Methoden-Parameter vom Typ org.springframework.security.core.Authentication verwendet werden. Spring füllt diese dann mit Informationen zum angemeldeten Benutzer, falls es einen gibt.
@GetMapping
fun someApi(@Parameter(hidden = true) authentication: Authentication?) {}
Damit diese in der API-Doku nicht auftauchen, sollte sie mittels @Parameter(hidden = true) ausgeblendet werden.
Ist authentication nicht null können dann beispielsweise der Benutzername (name) und die Authorities (authentication) abgefragt werden.
Wenn man im Falle von Access Tokens an den verwendeten Token kommen möchte, prüft man, ob authentication vom Typ BearerTokenAuthentication ist und greift dort auf token.tokenValue zu. Auch die Token-Attribute können als tokenAttributes gelesen werden.
Manchmal braucht man aber mehr Informationen zum Benutzer, da bietet sich eine Instanz der User-Entity an. Um einen Parameter vom Entity-Typ (User) zu benutzen, muss ein HandlerMethodArgumentResolver geschrieben werden.
@Component
class UserResolver(
private val appUserService: AppUserService
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
val type = parameter.parameterType
return type == User::class.java
}
override fun resolveArgument(parameter: MethodParameter, bindingContext: BindingContext, exchange: ServerWebExchange): Mono<Any> {
return ReactiveSecurityContextHolder.getContext().flatMap { context ->
context.authentication.toMono()
}.flatMap { auth ->
appUserService.loadByUsername(auth.name)
}
}
}
Dieser wird von Spring automatisch gefunden und benutzt, um in API-Methoden Parameter vom entsprechenden Typ zu befüllen.