26. srpna 2007

Zablokování účtu pro přihlášení uživatele

Během testování aplikace jsme si všimnuli, že je možné se donekonečna zkoušet přihlašovat do naší aplikace, což je svým způsobem bezpečnostní díra do aplikace, případně do celého systému, protože útočník pak může donekonečna zkoušet hesla až nakonec nějaké třeba zjistí.

Domluvili jsme se tedy, že upravíme náš bezpečnostní modul tak, že uživatel (= jedno uživatelské jméno) bude mít tři pokusy na přihlášení a pokud je vypotřebuje, tak pak se nebude moci po dobu 30 minut přihlásit.

Ještě malé vysvětlení pro začátek: bezpečnostní modul je v našem pojetí zapouzdřená Acegi security knihovna vhodně nakonfigurována dle potřeb projektu (rád bych toto rozebral v některém z dalších příspěvků).

Postup implementace je následující:

  1. někde musím odchytávat událost, kdy se uživatel špatně přihlásí. Vytvořím si vlastní ApplicationListener.
  2. potřebuji mít nějakého manažera, který bude řídit blokování účtů a bude říkat, zda daný uživatel se může přihlásit nebo ne
  3. nakonec je potřeba upravit vlastní zpracování přihlašovacího formuláře, což je standardně řešeno ve filtru AuthenticationProcessingFilter.

Vlastní ApplicationListener


/**
* Listener for catching authentification failures.
*
* Listener is also responsible for deciding whether user account should be
* disabled.
*
* @author pjuza@anect.com
*/
public class AuthentificationFailureListener implements ApplicationListener {

//~ Static fields/initializers =====================================================================================

private static final Log logger = LogFactory.getLog(AuthentificationFailureListener.class);

/**
* Maximum number of failures before user account will be disabled.
*/
private static final int MAX_COUNT_FAILURES = 3;

/**
* Map [user name, count of failures] maps users and theirs count of failures.
*/
private Map userFailures = new HashMap();

private DisabledAccountManager disabledAccountManager;

public void setDisabledAccountManager(DisabledAccountManager disabledAccountManager) {
this.disabledAccountManager = disabledAccountManager;
}

//~ Methods ========================================================================================================

public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof AbstractAuthenticationEvent) {
AbstractAuthenticationEvent authEvent = (AbstractAuthenticationEvent) event;
String username = authEvent.getAuthentication().getName();

if (event instanceof AbstractAuthenticationFailureEvent) {
//authentication failed
if (userFailures.containsKey(username)) {
int failuresCount = userFailures.get(username);
failuresCount++;

if (failuresCount < MAX_COUNT_FAILURES) {
userFailures.put(username, failuresCount);

if (logger.isDebugEnabled()) {
logger.debug("User " + username + " has increased number of failures.");
}
}
else {
//disable user account
disabledAccountManager.disableUserAccount(username);
userFailures.remove(username);
}
}
else {
userFailures.put(username, 1);

if (logger.isDebugEnabled()) {
logger.debug("User " + username + " was added into map of failures.");
}
}
}
else if (event instanceof AuthenticationSuccessEvent) {
//success authentication
if (userFailures.containsKey(username)) {
userFailures.remove(username);

if (logger.isDebugEnabled()) {
logger.debug("User " + username + " was removed from map of failures.");
}
}
}
}
}
}

Tento listener zachytává události typu AbstractAuthenticationFailureEvent a ukládá si statistiku špatných pokusů pro každé uživatelské jméno. V případě, že uživatel překročí počet povolených přihlášení, tak se předá informace manažerovi. Pokud se uživatel přihlásí úspěšně (tj. událost typu AuthenticationSuccessEvent), tak pak se smaže záznam o uživateli v mapě špatných přihlášení.

Manažer pro blokování účtů

Manažer je představován rozhraním DisabledAccountManager.

/**
* Manager for enabling/disabling user accounts.
*
* Specific implementation of this interface determines whether account will
* be disabled on the presentation level, on data level or somewhere else.
*
* @author pjuza@anect.com
*/
public interface DisabledAccountManager {

/**
* Method decides whether account for specified username is disabled or not.
* @param username User name
* @return true if user has disabled account otherwise false
*/
public boolean isUserAccountDisabled(String username);

/**
* Method disables user account.
* @param username User name
*/
public void disableUserAccount(String username);
}

Konkrétní implementace tohoto rozhraní je závislá na požadavcích projektu. K zablokování účtu může dojít přímo v LDAPu nebo jinde, kde se uchovávají informace o uživatelích. Já jsem pro začátek zvolil asi nejjednodušší způsob a to ten, že uživateli, který vypotřeboval počet pokusů na přihlášení, se zamezí možnost přihlášení po dobu dalších 30 minut.
Hned zde podotýkám, že toto řešení funguje za předpokladu, že aplikační server nebude restartován, protože informace jsou ukládány pouze v paměti.

/**
* Basic implementation of manager for disabling user accounts.
*
* @author pjuza@anect.com
*/
public class BasicDisabledAccountManager implements DisabledAccountManager {
private static final Log logger = LogFactory.getLog(BasicDisabledAccountManager.class);

/**
* Time determines how long user account will be disabled.
* Time is in milliseconds. Default time is 30 minutes.
*/
private int disablingTime = 30 * 60 * 1000;

/**
* Map [user name, date of inserting in ms] maps users and date of inserting item.
*/
private Map diabledUsers = new HashMap();

public void setDisablingTime(int disablingTime) {
this.disablingTime = disablingTime;
}

public void disableUserAccount(String username) {
diabledUsers.put(username, (new Date()).getTime());

if (logger.isDebugEnabled()) {
logger.debug("Account for " + username + " was disabled.");
}
}

public boolean isUserAccountDisabled(String username) {
if (diabledUsers.containsKey(username)) {
Long elapsedTime = (new Date()).getTime() - diabledUsers.get(username);

if (elapsedTime > disablingTime) {
diabledUsers.remove(username);

if (logger.isDebugEnabled()) {
logger.debug("Account for " + username + " was enabled again.");
}
}
}

return diabledUsers.containsKey(username);
}
}

Zpracování dat z přihlašovacího formuláře

Pro zpracování dat z přihlašovacího formuláře slouží třída AuthenticationProcessingFilter. Tuto třídu rozšíříme o kontrolu, zda uživatel, který se zkouší přihlásit nemá mít zablokovaný účet.

/**
* This class represents custom implementation of authentification filter.
* What is new is ability to disable user account for some time. In other words
* user won't be able to login for some time.
*
* If specified {@link DisabledAccountManager} then this manager decides whether
* user can be authentificated or not, if user account is disabled or not.
*
* This filter should be used with {@link AuthentificationFailureListener} that
* watches authentication failures and calls manager for disabling accounts.
*
* @author pjuza@anect.com
*/
public class DisableAuthenticationProcessingFilter extends AuthenticationProcessingFilter {

private static final Log logger = LogFactory.getLog(DisableAuthenticationProcessingFilter.class);

private DisabledAccountManager disabledAccountManager;

public void setDisabledAccountManager(DisabledAccountManager disabledAccountManager) {
this.disabledAccountManager = disabledAccountManager;
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request) throws AuthenticationException {
String username = obtainUsername(request);

//check if user can login
if (disabledAccountManager != null
&& disabledAccountManager.isUserAccountDisabled(username)) {
// Place the last username attempted into HttpSession for views
request.getSession().setAttribute(ACEGI_SECURITY_LAST_USERNAME_KEY, username);

logger.warn("User " + username + " is trying to login but his account is disabled for some time.");

throw new DisabledException("Account is disabled for user " + username);
}

//"normal" authentication
return super.attemptAuthentication(request);
}
}


Nakonec nezapomenout na konfiguraci Acegi:

<!-- Processes an authentication form. -->
<bean id="authenticationProcessingFilter" class="cz.anect.securitymodule.DisableAuthenticationProcessingFilter">
<property name="authenticationManager" ref="authenticationManager"/>
<property name="authenticationFailureUrl" value="${acegi.authenticationFailureUrl}"/>
<property name="defaultTargetUrl" value="/"/>
<property name="filterProcessesUrl" value="/j_acegi_security_check"/>
<property name="disabledAccountManager" ref="disabledAccountManager"/>
</bean>

<bean id="failureListener" class="cz.anect.securitymodule.AuthentificationFailureListener">
<property name="disabledAccountManager" ref="disabledAccountManager"/>
</bean>

<bean id="disabledAccountManager" class="cz.anect.securitymodule.BasicDisabledAccountManager">
</bean>

Já jsem si ještě upravil formulář pro přihlašování a to tak, že odchytávám vyjímku DisabledException a podle toho vypisuji hlášku uživateli o zablokovaném účtu.

6 komentářů:

Jan Novotný řekl(a)...

K indikaci zablokovaného účtu by se dala použít také standardní metoda rozhraní UserDetails.

Viz. http://www.acegisecurity.org/multiproject/acegi-security/apidocs/org/acegisecurity/userdetails/UserDetails.html#isAccountNonLocked()

Nebylo by tak potřeba extendovat AuthenticationProcessingFilter a dělat si vlastní chybovou hlášku. Řekl bych, že právě o tomhle uvedená metoda v rozhraní je.

Petr Jůza řekl(a)...

Máš pravdu, že UserDetails tuto metodu má. O této metodě jsem věděl (píši v minulém čase, protože již je to nějaký čas, co jsem toto implementoval) a asi hlavní dva důvody, proč jsem tuto metodu nevyužil, jsou následující: 1)UserDetails a různé implementace nemají settery, vše je nutné nastavovat pomocí konstruktoru. Což by samozřejmě nebyl problém vytvořit nový objekt s novými parametry, ale zde se mi to moc nezdálo, že je to úplně čisté. 2) jednotlivé atributy UserDetails nastavuji při načítání uživatele z LDAPu a nastavuji ho dle určitých algoritmů dle parametrů v LDAPu. Nechtěl jsem také míchat výsledek tohoto nastavení s dočasným omezením na přihlášení, což spíše vidím jako aplikační záležitost.

Vlastní chybovou hlášku jsem chtěl, abych právě rozlišil zablokování z důvodu špatných přihlášení a důvodu, že např. administrátor zablokoval uživateli účet.

Anonymní řekl(a)...

dakujem, velmi pekny clanok. mam ale jednu otazku, preto je potrebne odchytavam udalost (a tym padom potrebujem listener), nie je mozne danu funkcionalitu dat do funkcie unsuccessfulAuthentication v odvodenom AuthenticationProcessingFilter. dakujem. Ivan

Anonymní řekl(a)...

resp. preco nie do onUnsuccessfulAuthentication,co je dalsia moznost, ked nemam vlastnu funkciu unsuccessfulAuthentication ?
dakujem. Ivan

Petr Jůza řekl(a)...

Již je to nějaký čas, co jsem to implementoval, ale myslím, že jste objevil "lepší" cestu než přes listener. Nevidím důvod, proč to tedy nevyužít, já jsem si těchto metod nevšimnul.

Petr Jůza řekl(a)...

Napadla mě jedna výhoda použití listeneru - mám většinou více autentifikačních filtrů (pro formulář, NTLM, ...) a díky listeneru mám logování na jednom místě.