Mamy już skonfigurowaną aplikację Guice+Vaadin. Nawet działa 😉 Czas zatem dodać możliwość logowania się do aplikacji. W tym celu wykorzystamy Apache Shiro, które dawniej zwało się JSecurity.

Uwaga! Wszelkie adnotacje związane z DI pochodzą z pakietu javax.inject. Guice od wersji 3 w pełni wspiera JSR 330.

Chwila teorii i konfiguracji

W poprzednim odcinku poświęconym konfiguracji projektu możecie zobaczyć, że w zależnościach dodany jest shiro-web. Jak wiele innych frameworków tak i Shiro wykorzystuje sesję serwera do składowania informacji o aktualnym użytkowniku. Informacje te „obrabiane są” w filtrze zanim jeszcze żądanie trafi do serwletu. Pozwala to na w miarę dobre odseparowanie logiki bezpieczeństwa od logiki biznesowej. Filtr w przypadku zestawu Shiro + Guice jest bardzo prosty:

Listing 1. GuiceShiroFilter

@Singleton
public class GuiceShiroFilter extends AbstractShiroFilter {

	@Inject
	public GuiceShiroFilter(WebSecurityManager securityManager) {
		this.setSecurityManager(securityManager);
	}

}

Kolejnym elementem jest przygotowanie sobie jakiegoś wygodnego narzędzia, które pozwoli nam na dostęp do aktualnego użytkownika za pomocą @Inject. W tym celu należy przygotować sobie klasę Providera, która będzie dostarczać nam obiekty typu Subject. Interfejs Subject reprezentuje aktualnego użytkownika. Pozwala na dokonywanie operacji takich jak logowanie do serwisu czy sprawdzanie uprawnień oraz ról. Standardowo można go pozyskać za pomocą SecurityUtils.getSubject(). Nasze zadanie ograniczy się zatem do napisania prostego wrappera.

Listing 2. SubjectProvider

public class SubjectProvider implements Provider<subject> {

	@Override
	public Subject get() {
		return SecurityUtils.getSubject();
	}

}</subject>

Ostatnim elementem, który należy na tym etapie napisać jest implementacja interfejsy Realm. Interfejs ten reprezentuje źródło informacji o użytkownikach ich rolach i przywilejach. Zazwyczaj jest odwzorowaniem jakiegoś rodzaju DataSource. W paczce mamy dostarczone implementacje dla LDAP, ActiveDirecotry, JDBC, JNDI oraz dwaoparte o pliki properties oraz INI (format Shiro). Poważną bolączką jest brak wsparcia dla JPA, ale napisanie odpowiedniej implementacji nie jest trudne.

Listing 3. SimpleRealm

public class SimpleRealm extends AuthorizingRealm {

	@Inject
	public void setName(@Named("realmName") String name) {
		super.setName(name);
	}

	@Override
	protected AuthorizationInfo doGetAuthorizationInfo(
			PrincipalCollection principals) {
		String userName = (String) (principals.fromRealm(getName()).iterator()
				.next());
		SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
		if ("admin".equals(userName))
			simpleAuthorizationInfo.addRole("admin");
		else
			simpleAuthorizationInfo.addRole("nonAdmin");
		return simpleAuthorizationInfo;
	}

	@Override
	protected AuthenticationInfo doGetAuthenticationInfo(
			AuthenticationToken token) throws AuthenticationException {
		String username = ((UsernamePasswordToken) token).getUsername();
		String passwd = new String(
				((UsernamePasswordToken) token).getPassword());
		if (username.equals(passwd)) {
			SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(
					username, passwd, getName());

			return simpleAuthenticationInfo;
		}
		return null;
	}

}

Na chwilę obecną interesuje nas tylko metoda doGetAuthenticationInfo, w której sprawdzana jest poprawnosc pary nazwa użytkownika i hasło. Shiro ma własne implementacje najpopularniejszych algorytmów haszujących m.n. MD5, SHA1, SHA256. w tym miejscu można ich użyć.

Zbieramy to do kupy

Czas na zebranie tego wszystkiego do kupy. Stworzymy osobny moduł Guice, który będzie nam pomagał zarządzać bezpieczeństwem. Nazwiemy go SecurityModule.

Listing 4. SecurityModule

public class SecurityModule extends AbstractModule {

	private Class extends Realm> userSecurityRealmImpl;

	public SecurityModule(Class extends Realm> userSecurityRealmImpl) {
		this.userSecurityRealmImpl = userSecurityRealmImpl;
	}

	@com.google.inject.Provides
	@Singleton
	public DefaultWebSecurityManager getSecurityManager(Realm realm) {
		return new DefaultWebSecurityManager(realm);
	}

	@Override
	protected void configure() {

		bind(String.class).annotatedWith(Names.named("realmName")).toInstance(
				"MyRealm");
		bind(Realm.class).to(userSecurityRealmImpl);
		bind(Subject.class).toProvider(SubjectProvider.class).in(
				ServletScopes.SESSION);
		bind(WebSecurityManager.class).to(DefaultWebSecurityManager.class);
		bind(LoginService.class).to(LoginServiceImpl.class).in(ServletScopes.SESSION)
		SecurityInterceptor interceptor = new SecurityInterceptor();

		requestInjection(interceptor);
		bindInterceptor(Matchers.any(),
				Matchers.annotatedWith(SecureAction.class), interceptor);
	}

}

Na tym etapie nie interesuje nas używany interceptor. O tym będzie później przy temacie uprawnień. Teraz przyjrzymy się procesowi logowania i interfejsowi LoginService

Logowanie i wylogowanie

Proces logowania powinien składać się z trzech etapów. Pierwszy to pobranie danych od użytkownika. Drugi to ich weryfikacja, a trzeci przekazanie informacji o wyniku weryfikacji.

Nasze rozwiązanie nie ma być super hiper elastyczne zatem połączymy te dwa elementy w jeden. Interfejs LoginService będzie zatem udostępniał jedną metodę, która będzie przesłaniać cały proces – wyświetlenie formularza i weryfikację danych.

Listing 5. LoginService

public interface LoginService {

	public static class LoginEvent {

		private LoginStatus loginStatus;

		public LoginEvent(LoginStatus loginStatus) {
			this.loginStatus = loginStatus;
		}

		public LoginStatus getLoginStatus() {
			return loginStatus;
		}

	}

	public static interface LoginListener {
		public void login(LoginEvent event);
	}

	public static enum LoginStatus {
		OK, ERROR, ALREADY_DONE;
	}

	public static class LogoutEvent {

		private LoginStatus loginStatus;

		public LogoutEvent(LoginStatus loginStatus) {
			this.loginStatus = loginStatus;
		}

		public LoginStatus getLoginStatus() {
			return loginStatus;
		}
	}

	public static interface LogoutListener {
		public void logout(LogoutEvent event);
	}

	public void addLoginListener(LoginListener listener);

	public void addLogoutListener(LogoutListener listener);

	public void removeLoginListener(LoginListener listener);

	public void removeLogoutListener(LogoutListener listener);

	void login();

	void logout();
	
	void hide();
}

Interfejs jest trochę przeładowany, ale w praktyce każdy interfejs wspierający zdarzenia będzie taki. Wewnętrzne klasy i interfejsy są w duchu Vaadin i powiem szczerze, że przekonałem się do nich właśnie dzięki Vaadin. Mają pewne zalety, o których trzeba by było napisać.

W praktyce interfejs ma trzy metody służące do logowania, wylogowania i ukrycia formularza. Ta ostatnia jest wynikiem statusu ALREADY\_DONE, który odpowiada za sytuację gdzie użytkownik próbuje się zalogować jednak już jest zalogowany.

Wybrałem wersję z obsługą zdarzeń ponieważ powiadomienie o statusie operacji musi być obsłużone w dwóch miejscach. Z jednej strony będzie obsługiwane w samej implementacji interfejsu, a z drugiej użytkownik może chcieć wykonać jakieś dodatkowe akcje po zalogowaniu w zależności od tego co się stało.

Implementacja interfejsu jest dość obszerna zatem przedstawię tylko to co rzeczywiście jest potrzebne.

Logowanie

Listing 6. Metoda login z LoginServiceImpl

	public void login() {
		if (subject.isAuthenticated()) {
			application.getMainWindow().showNotification(
					new AlreadyLogedinNotification());
			fireLoginEvent(LoginStatus.ALREADY_DONE);
			hide();
			return;
		}
		loginWindow = new LoginWindow("Zaloguj");
		loginWindow.setLoginAction(new LoginAction() {
			@Override
			public void login() {

				UsernamePasswordToken token = loginWindow.getToken();
				token.setRememberMe(true);
				try {
					subject.login(token);
					fireLoginEvent(LoginStatus.OK);
					hide();
				} catch (AuthenticationException e) {
					fireLoginEvent(LoginStatus.ERROR);
					application.getMainWindow().showNotification(
							new IncorrectLoginNotification());
				}

			}
		});

		application.getMainWindow().addWindow(loginWindow);
	}

W metodzie login tworzymy nowe okno logowania i dodajemy do niego akcję obsługującą proces logowania. Jeżeli istniejący w sesji użytkownik jest już zalogowany to wyświetlamy komunikat i kończymy działanie. Akcja logowania polega na pobraniu tokena z okna logowania, ustawienia flagi obsługującej zapamiętanie użytkownika i wywołanie metody login z interfejsu Subject. W przypadku błędnych danych, a poprawność sprawdzana jest w implementacji Realm, zwracany jest wyjątek, który obsługujemy poprzez wywołanie zdarzenia logowania ze statusem ERROR. Nie lubię sterowania wyjątkami.

W samej aplikacji obsługa logowania jest bardzo prosta:

Listing 7. Metoda init w głównej klasie aplikacji

	public void init() {
		window = new Window("My Vaadin Application");
		setMainWindow(window);
		login = new Button("Login");
		loginAction = new Button.ClickListener() {
			public void buttonClick(ClickEvent event) {
				loginService.login();
			}
		};
		login.addListener(loginAction);
		loginService.addLoginListener(this);
		window.addComponent(login);
	}

	public void login(LoginEvent event) {
		if (event.getLoginStatus() == LoginStatus.OK) {
			login.removeListener(loginAction);
			login.setCaption("Logout");
			login.addListener(new ClickListener() {

				@Override
				public void buttonClick(ClickEvent event) {
					loginService.logout();
				}
			});

			Button adminAction = new Button("Admin action");
			adminAction.addListener(new ClickListener() {

				@Override
				public void buttonClick(ClickEvent event) {
					adminAction();
				}
			});
			window.addComponent(adminAction);

			Button notAdminAction = new Button("Not Admin action");
			notAdminAction.addListener(new ClickListener() {

				@Override
				public void buttonClick(ClickEvent event) {
					notAdminAction();
				}
			});
			window.addComponent(notAdminAction);
		}
	}

Metoda login pochodzi z interfejsu LoginListener. Sama klasa główna jest też dodana jako LoginListener do LoginService

Wylogowanie

Proces kończenia pracy użytkownika też wymaga kilku kroków. Po pierwsze należy powiadomić zainteresowanych o zakończeniu pracy. Następnie należy zamknąć aplikację, sesję użytkownika Shiro oraz na koniec ubić sesję serwera. Wszystkie te kroki są wykonywane w metodzie logout:

Listing 8. Metoda logout z LoginServiceImpl

	@Override
	public void logout() {
		fireLogoutEvent(new LogoutEvent(LoginStatus.OK));
		application.close();
		subject.logout();
		serverSession.invalidate();
	}

Ostatnia linijka wymaga wstrzyknięcia sesji HTTP bezpośrednio do serwisu, a to nie jest najbardziej eleganckie rozwiązanie. Ważne, że skuteczne.

Może zdarzyć się, że w wyniku wylogowania poleci IllegalStateException. Oznacza to, że sesja już się zakończyła w momencie wywołania subject.logout(). Jednak nie jest to regułą i jedyne co możemy zrobić to wzbogacić ten fragment kodu o blok try/catch, który będzie robił radosne nic.

Na dziś tyle. W ostatniej części wyjaśnię do czego służy nam interceptor oraz jak ładnie budować UI.