Guice, Vaadin i Shiro – część 1
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
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.