Während unserer Arbeit mit einer Microservice-Architektur und Spring Boot taucht immer wieder ein Problem auf:
Jeder Nutzer sollte sich gegen alle unsere Schnittstellen authentifizieren, aber man möchte nicht für jede Schnittstelle einen eigenen Login schreiben und verwalten. Die Lösung ist ein so genannter Single Sign On (SSO). In diesem Blogartikel wird beschrieben, wie man einen SSO mit Spring Boot 2.0 mit einem Authentifizierungs- und einen Ressourcenserver über ein JWT-Token und OAuth2 realisiert.
Gradle-Datei(en)
Die Gradle-Build-Dateien unterscheiden sich für den Authentifizierungs- und den Ressourcenserver nicht. Für beide werden die Spring-Frameworks Spring-Security-OAuth2 und Spring-Security-Jwt benötigt. Daraus resultieren folgende Gradle-Build-Dateien:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
buildscript { ext { springBootVersion = '2.0.3.RELEASE' } repositories { mavenCentral() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") } } apply plugin: 'java' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' group = 'de.softwareforen.testSecurity' version = '0.0.1' sourceCompatibility = 1.8 repositories { mavenCentral() } dependencies { compile('org.springframework.boot:spring-boot-starter-web') compile('org.springframework.security.oauth:spring-security-oauth2:2.3.3.RELEASE') compile('org.springframework.security:spring-security-jwt:1.0.9.RELEASE') } |
Der Authentifizierungsserver
Der Authentifizierungsserver soll in unserem Testprojekt auf Port 9999 laufen. Da der Standardport Port 8080 ist, muss folgender Eintrag in die application.properties-Datei übernommen werden:
1 |
server.port = 9999 |
Zudem soll in unserem Projekt mit möglichst geringem Aufwand und ohne zusätzliche Abhängigkeiten eine Datenbank simuliert werden. Dies geschieht über eine weitere Properties-Datei, welche für jeden User das Passwort und die entsprechenden Rechte enthält. Die Datei muss im Projekt im Ressourcen-Ordner (neben der application.properties-Datei) angelegt werden und sollte folgenden Inhalt haben:
1 2 3 4 5 6 |
# Data for reader reader.password = reader reader.authorities = READ # Data for Writer writer.password = writer writer.authorities = READ, WRITE |
Die meist erzeugte Klasse, welche die main-Methode und die SpringBootApplication-Annotation enthält, muss von uns nicht angepasst werden.
Nutzerdaten auslesen
Nachdem nun das grundsätzliche Setup für den Authentifizierungsserver fertiggestellt ist, werden als Erstes die Nutzerdaten aus der soeben angelegten Datei ausgelesen. Dies geschieht mit Hilfe des UserService, welcher im Folgenden dargestellt ist:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
@Service public class UserService implements UserDetailsService { private static final String PROPERTIES_PATH = "/user.properties"; private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String PASSWORD_PROPERTY_NAME = ".password"; private static final String AUTHORITIES_PROPERTY_NAME = ".authorities"; private Properties properties; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { String password = getPasswordFromProperties(username); List roles = getRolesFromProperties(username); // Wandle Strings in Rollen um List grantedAuthorities = new ArrayList<>(); if (roles != null && password != null) { for (String role : roles) { grantedAuthorities.add(new SimpleGrantedAuthority(role)); } return new User(username, password, grantedAuthorities); } return null; } private String getPasswordFromProperties(String username) { try { // Falls keine Properties vorhanden, hole neu if (properties == null) { properties = new Properties(); InputStream inputStream = getClass().getResourceAsStream(PROPERTIES_PATH); properties.load(inputStream); } // Lies Passwort des Nutzers return properties.getProperty(username + PASSWORD_PROPERTY_NAME); } catch (IOException e) { properties = null; logger.info("Could not find user!", e); } return null; } private List getRolesFromProperties(String username) { try { // Falls keine Properties vorhanden, hole neu if (properties == null) { properties = new Properties(); InputStream inputStream = getClass().getResourceAsStream(PROPERTIES_PATH); properties.load(inputStream); } // Lies Rechte des Nutzers String roleProperties = properties.getProperty(username + AUTHORITIES_PROPERTY_NAME); String[] roles = roleProperties.split(","); return new ArrayList<>(Arrays.asList(roles)); } catch (IOException e) { properties = null; logger.info("Could not find user!", e); } return null; } } |
Konfigurieren der Absicherung
Nun muss noch festgelegt werden, welche Anfragen an den Server selbst zugelassen sind. Dazu wird die Klasse WebSecurityConfig erzeugt. Diese hat grundsätzlich den selben Inhalt wie bei einer normalen abgesicherten Anwendung. Für das Beispielprojekt wurde ein NoOpPasswordEncoder verwendet. Dieser verwendet Passwörter, welche im Klartext hinterlegt sind. Für ein Beispiel ist dies ausreichend, produktiv sollte er allerdings ausgetauscht werden. Die komplette WebSecurityConfig für unser Testprojekt sieht wie folgt aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@Configuration class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserService userService; @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { // Setze Fehlerhandling, Alle Requests müssen authorisiert sein http.csrf().disable().exceptionHandling() .authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)) .and() .authorizeRequests().antMatchers("/**").authenticated().and().httpBasic(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // Setze den Service, welcher die Nutzerdaten bereitstellt auth.userDetailsService(this.userService); } @Bean public static NoOpPasswordEncoder passwordEncoder() { // Erstelle einen NoOpPasswort-Encoder für Plaintext-Passwörter // Nicht Produktiv verwenden! return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance(); } } |
Als vorletzten Schritt zur Fertigstellung des Servers benötigen wir noch die Konfiguration für den Authentifizierungsserver selbst. Diese legt fest, für welche Clients welche Rechte existieren und wie die Token erzeugt werden. Diese Konfiguration heißt OAuth2Configuration und ist im Folgenden abgebildet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@Configuration @EnableAuthorizationServer public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManagerBean; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // Verschiedene Einstellungen zum "Login" bzw. zur Authorisierung clients.inMemory() .withClient("sfl_app") .scopes("SFL") .autoApprove(true) .authorities("READ", "WRITE") .authorizedGrantTypes("refresh_token", "password"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // Einstellungen zur Erzeugung des Tokens TokenStore tokenStore = new JwtTokenStore(jwtTokenEnhancer()); endpoints.tokenStore(tokenStore) .tokenEnhancer(jwtTokenEnhancer()) .authenticationManager(authenticationManagerBean); } private JwtAccessTokenConverter jwtTokenEnhancer() { KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "mySecretKey".toCharArray()); JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwt")); return converter; } } |
JWT-Token Verwaltung
Um unsere Token zu verwalten, benötigen wir noch einen JWT-Token-Store. Dieser muss erstellt und ebenfalls im Ressourcenordner (neben der application.properties-Datei) abgelegt werden. Zusätzlich dazu wird noch ein Public Key erzeugt, mit dem sich später unsere Schnittstellen gegenüber unserem Authentifizierungsserver authentifizieren und die Token ohne den Server auswerten können. Dazu müssen folgende Befehle nacheinander ausgeführt werden (Ein Hinweis an alle Windows-Nutzer: Falls OpenSSL nicht vorhanden ist, in der Git Bash wird es mitgeliefert):
1 2 |
keytool -genkeypair -alias jwt -keyalg RSA -dname "CN=jwt, L=Berlin, S=Berlin, C=DE" -keypass mySecretKey -keystore jwt.jks -storepass mySecretKey keytool -list -rfc --keystore jwt.jks | openssl x509 -inform pem -pubkey |
Danach wird mit dem Keystore-Kennwort „mySecretKey“ bestätigt und es sollte eine ähnliche Ausgabe folgen:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
+QIDAQAB
-----END PUBLIC KEY-----
-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIEOkszIDANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQGEwJE
RTEPMA0GA1UECBMGQmVybGluMQ8wDQYDVQQHEwZCZXJsaW4xDDAKBgNVBAMTA2p3
dDAeFw0xNjAyMDExNzQwMTlaFw0xNjA1MDExNzQwMTlaMD0xCzAJBgNVBAYTAkRF
MQ8wDQYDVQQIEwZCZXJsaW4xDzANBgNVBAcTBkJlcmxpbjEMMAoGA1UEAxMDand0
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR84LFHwnK5GXErnwkmD
mPOJl4CSTtYXCqmCtlbF+5qVOosu0YsM2DsrC9O2gun6wVFKkWYiMoBSjsNMSI3Z
w5JYgh+ldHvA+MIex2QXfOZx920M1fPUiuUPgmnTFS+Z3lmK3/T6jJnmciUPY1pe
h4MXL6YzeI0q4W9xNBBeKT6FDGpduc0FC3OlXHfLbVOThKmAUpAWFDwf9/uUA//l
3PLchmV6VwTcUaaHp5W8Af/GU4lPGZbTAqOxzB9ukisPFuO1DikacPhrOQgdxtqk
LciRTa884uQnkFwSguOEUYf3ni8GNRJauIuW0rVXhMOs78pKvCKmo53M0tqeC6ul
+QIDAQABoyEwHzAdBgNVHQ4EFgQUxebRVICNPg65T2RgrOe2J5qDMXMwDQYJKoZI
hvcNAQELBQADggEBAAHBF3JQ2ZsEjuYYwU5cp9BzBJoTQyChF37AA76EorrcSeqo
Rui1dUIfXbImIOZ5PBNk34IFWROTwpw80zBCZQ7NQ81ITzuhsxjbX7Wxj6iCq3u9
TDN+IxiaZMvJ2PDfeRqr93HOwMTMttxyW4KVa3geQ+yMMSZrxagEpqMA1Fviqa6T
5u8DNqfXQ8Hg+yG2bMNQs6GleAFkRprkHjR6yY7ehmIVMZ7iBkkXh8IO8fKy2WNK
uWa+DO2lXJj1W7HLXeaeT0twAqwyoNj2/pxMuv/JrTlNkhcUTmP+UBAJZih0KSGD
9TSKs5HlBGsIUpILuauNzZk1VS2RCyVtD1zf7vM=
-----END CERTIFICATE-----
Die erzeugte Datei jwt.jks ist der Keystore und muss in den Ressourcenordner verschoben werden. Der Teil der Ausgabe, welcher den Public Key darstellt, wird kopiert und zusammen mit der Anfangs- und Endzeile in eine neue Datei public.cert ausgelagert. Diese Datei muss in den Ressourcenordner des Ressourcenservers verschoben werden.
Der Ressourcenserver
Auch der Ressourcenserver erhält, wie der Authentifizierungsserver, seinen eigenen Port. Diesmal den Port 9090. Dazu muss wieder folgende Zeile in die application.properties übernommen werden.
1 |
server.port = 9090 |
Auch hier muss die selbst erzeugte Klasse mit der main-Methode zum Starten des Projekts nicht verändert werden.
Setzen der JWT-Token
Nachdem der Public Key des Authentifizierungsservers hinterlegt wurde, muss dieser ausgewertet werden. Dies geschieht mit der der Klasse JwtConfiguration. Diese enthält folgenden Quellcode, welcher den Schlüssel ausliest und für diese Anwendung setzt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Configuration public class JwtConfiguration { @Bean protected JwtAccessTokenConverter jwtTokenEnhancer() { // Konfiguriere, welcher Key als Authentifizierung für die Schnittstelle an den Authentifizierungsservice weitergeleitet wird String publicKey; try { Resource resource = new ClassPathResource("public.cert"); publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream())); } catch (IOException e) { throw new RuntimeException(e); } JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(publicKey); return converter; } } |
Konfiguration des Ressourcenservers
Nun folgt die eigentliche Konfiguration des Ressourcenservers. Es wird wieder festgelegt, welche Anfragen abgesichert werden, welche nicht und wie das Token weiter verwendet wird. Zudem kann schon hier festgelegt werden, welche Anfragen welche Rechte benötigen. Eine Konfiguration über Annotationen direkt im Contoller ist aber praktikabler. Der Quellcode der Klasse sieht dann folgendermaßen aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Autowired private JwtAccessTokenConverter jwtAccessTokenConverter; @Override public void configure(HttpSecurity http) throws Exception { // Alle Requests müssen Authorisiert sein http.csrf().disable().authorizeRequests().antMatchers("/**").authenticated(); // Die entsprechenden Rechte für die Endpunkte können hier zugewiesen werden // Zuweisung über Annotations scheint sinnvoller // .antMatchers(HttpMethod.GET, "/api").hasAuthority("READ"); // .antMatchers(HttpMethod.POST, "/api").hasAuthority("WRITE"); } @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { // Stelle den Token für die Schnittstelle bereit TokenStore tokenStore = new JwtTokenStore(jwtAccessTokenConverter); resources.resourceId("api").tokenStore(tokenStore); } } |
Um auch die Annotationen der Frameworks voll auszunutzen, wird die Klasse GlobalMethodSecurityConfiguration benötigt. Sie aktiviert eben jene Annotationen und hat folgenden Inhalt:
1 2 3 4 5 |
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class GlobalMethodSecurityConfiguration { // Schaltet ähnlich zu @EnableWebSecurity die Authentifizierung und deren Annotationen zu } |
Als letztes müssen dem Ressourcenserver Schnittstellen mit auf den Weg gegeben werden. Dazu wird ein entsprechender Controller erstellt. In diesem Controller gibt es nichts weiter Außergewöhnliches, nur die Annotation @PreAuthorize ist neu. Diese Annotation legt fest, dass nur Nutzer mit bestimmten Rechten diese Methode ausführen und damit den Endpunkt ansprechen können. Weitere Annotationen, wie z.B. @PostAuthorize, und deren Verwendung können der Dokumentation des Frameworks entnommen werden. Unser Controller hat zwei Endpunkte mit folgendem Inhalt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@RestController @RequestMapping("/api") public class Controller { @PreAuthorize("hasAuthority('READ')") @RequestMapping(value = "read", method = RequestMethod.GET) public String read(Principal principal) { return "read " + principal.getName(); } @PreAuthorize("hasAuthority('WRITE')") @RequestMapping(value = "write", method = RequestMethod.POST) public String write(Principal principal) { return "write " + principal.getName(); } } |
Ablauf der Authentifizierung und Testen der Implementierung
Um sich zu authentifizieren, muss sich jeder Nutzer zunächst bei unserem Authentifizierungsserver anmelden. Dort erhält er ein (oder mehrere) Token. Diese kann er danach für alle Requests nutzen.
Der Request auf den Authentifizierungsserver ist ein POST-Request und stellt sich für den Nutzer „reader“ folgendermaßen dar:
- POST auf sfl_app:@localhost:9999/oauth/token mit folgenden form-data-Body-Elementen: grant_type=password , username=reader , password=reader
In der Antwort ist unter anderem ein „access_token“ enthalten. Dieses kann für weitere Authentifizierungen genutzt werden. So sieht der Zugriff auf den Endpunkt „api/read“ des Ressourcenservers folgendermaßen aus:
- GET auf localhost:9090/api/read mit folgendem Header: Key = Authorization ; Value = Bearer + access_token
Wichtig ist hier, dass der Nutzer Reader nur auf die Ressource „api/read“ des Ressourcenservers zugreifen kann. Der Nutzer „writer“ hingegen, kann beide Endpunkte ( „api/read“ und „api/write“) nutzen.
Fertig!
Nach der Durchführung aller genannten Schritte sollte nun eine funktionierende, einfache Microservice-Architektur mit einem Authentifizierungs- und einem Ressourcenserver vorliegen. Diese kann noch beliebig um weitere Ressourcenserver erweitert werden. Zudem kann für die Verwaltung der Nutzer jegliche Datenbank über den UserService angebunden werden. Auch die Erzeugung der Token kann beliebig verändert werden. Zu beachten ist nur, dass für die Auswertung der JWT-Token keine Verbindung zum Authentifizierungsserver vorliegen muss. Dies kann bei anderen Vorgehensweisen anders sein.
Schreibe einen Kommentar