Case study. multi step login service - aegisql/conveyor GitHub Wiki

Table Of Content

Multi-step login service

This is a simplified version of a real life login service with complex multi-step user authentication and profile enrichment

Goal

  • Authenticate user
  • collect user profile data
  • create session and return client access token

Input.

User sends to the service three pieces of information

  • username
  • password
  • one-time password token

User Authentication Steps

To fully authenticate the user service must:

  • Authenticate user in LDAP with provided username and password
  • Retrieve user group settings from LDAP
  • Validate one time password token by sending a request to a special service
  • Load user's settings from a NoSQL database
  • Load Amazon's User profile from the Amazon cloud
  • Save user's IP address
  • Build user profile based on all collected information about the user

Output

If all previous steps finished successfully, server must:

  • save User's profile in memory, so that if user were inactive for certain period of time, opened session should expire and profile should be deleted.
  • return to the client a unique session ID
  • Asynchronously save Session in a NoSQL database for persistence.
  • There is a limited time window for all authentication steps.

API

Building conveyor is a perfect tool for this kind of tasks. In order keep this study short and clear lets assume that code that provides actual data access is available via following interfaces.

// This is the class where we keep User's Profile data
// Trivial POJO class with fields, like username, firstName, lastName, role, etc
public class UserInfo {
...
}
// UserInfoBuilder will collect all data and build final product
public class UserInfoBuilder implements Supplier<UserInfo> {
   ...
   public UserInfo get() ...
   ...
}

// Loging user in LDAP and load his LDAP profile and group info.
interface LdapSource {
   void ldapLogin(String username, String password);
   UserInfo getUserInfo(String username);
}

// Validate one time password token
interface TokenValidation {
   boolean validateToken(String token);
}

// Load service seetings for given user.
// This information can be empty, in this case default settings should be applied.
interface NoSQLSource {
   UserInfo getUserMethadata(String username);
}

// Retrieve and save UserInfo profile in the session database
// using unique session ID as a database primary key.
interface SessionSource {
   UserInfo getUserInfo(String token);
   void saveUserInfo(String token, UserInfo userInfo);
}

interface Cache {
   UserInfo getUserInfo(String token);
   void saveUserInfo(UserInfo userInfo);
}

// Ask Amazon that username also exists and has active profile
interface AmazonSource {
   boolean validateAmazonUser(String username);
}

We not providing implementation for those interfaces, but we can assume that:

  • All external calls are blocking
  • Complex things, like socket timeouts, connections, pooling, all hidden behind simple interfaces and out of scope of this example.
  • External APIs have no side effects, meaning they either return proper value or fail with exception depending on their nature.

Getting started

Key is a unique session UUID String

Product class is UserInfo

Labels. We have limited number of well defined initialization steps. Enum smart label will be perfect choice. Lets call it LoginStep.

public enum LoginStep implements SmartLabel<UserInfoBuilder> {
    LDAP_USER_INFO(UserInfoBuilder::ldapUserInfo),
    AWS_USER_INFO(UserInfoBuilder::awsUserInfo),
    USER_SETTINGS(UserInfoBuilder::userSettings),
    REMOTE_IP(UserInfoBuilder::remoteIp),
    USER_INFO(UserInfoBuilder::userInfo),
    ;
    BiConsumer<UserInfoBuilder, Object> setter;
    <T> LoginStep (BiConsumer<UserInfoBuilder,T> setter) {
        this.setter = (BiConsumer<UserInfoBuilder, Object>) setter;
    }
    @Override
    public BiConsumer<UserInfoBuilder, Object> get() {
        return setter;
    }
}

Thus, UserInfoBuilder should implement number of static methods with two parameters. First parameter is UserInfoBuilder, second of any type. Example:

public static void remoteIp(UserInfoBuilder b, String remoteIp) {
    b.userInfo.setRemoteIp(remoteIp);
}

This method will serve all messages labeled by LoginStep.REMOTE_IP

//Globally available resources
LdapSource ldapSource;
TokenValidation tokenValidator;
NoSQLSource noSqlSource;
SessionSource sessionSource;
Cache cache;
AmazonSource amazonSource;

// Also available standard Java thread pool
ExecutorService threadpool = Executors.newFixedThreadPool(THREAD_POOL_SIZE)

Conveyor will look like:

// Creating instance of the assembling conveyor
Conveyor<String,LoginStep,UserInfo> conveyor = new AssemblingConveyor<>();

// Configuration
conveyor.setName("Login Conveyor");
conveyor.setBuilderSupplier(UserInfoBuilder::new);
conveyor.setIdleHeartBeat(1, TimeUnit.SECONDS);
conveyor.setDefaultBuilderTimeout(10,TimeUnit.SECONDS);
conveyor.setResultConsumer(res->{
    LOG.debug("UserInfo Build Success: {}",res);
    // Synch save in Caching Conveyor 
    cache.saveUserInfo(res.product);
    // Asynch save in DB. Non critical if fails. 
    threadpool.submit(()->{ sessionSource.saveUserInfo(res.product); });
});
conveyor.setScrapConsumer(bin->{
     LOG.error("UserInfo Build Failed: {}",bin);
});

And finally, we can implement our service. Method login. Note asynchronous execution of all data sources.

public Response login(UserCredentials credentials, @Context HttpServletRequest request) {
	String username     = credentials.getUsername();
	String password     = credentials.getPassword();
	String accessToken  = credentials.getAccessToken();
	String remoteIp     = request.getRemoteAddr();

	String sessionToken = UUID.randomUUID().toString();
	CompletableFuture<UserInfo> uiFuture = loginConveyor.createBuildFuture(sessionToken, (st,un) -> new UserInfoBuilder(st,un));
	loginConveyor.add(sessionToken,remoteIp, LoginStep.REMOTE_IP);
	//Try to login
	threadpool.submit(()->{
		try {
			ldapSource.ldapLogin(username,password);
		} catch (Exception e) {
			loginConveyor.addCommand(new CancelCommand<String>(sessionToken ));
		}
	});
	//Try to validate token
	threadpool.submit(()->{
		try {
			tokenValidator.validateToken(accessToken);
		} catch (Exception e) {
			loginConveyor.addCommand(new CancelCommand<String>(sessionToken));
		}
	});
	//Retrieve User Info from LDAP
	threadpool.submit(()->{
		try {
			UserInfo rolesUserInfo = ldapSource.getUserInfo(username));
			loginConveyor.add(sessionToken,rolesUserInfo,LoginStep.LDAP_USER_INFO);
		} catch (Exception e) {
			loginConveyor.cancel(sessionToken);
		}
	});
	//Retrieve User Settings from DB
	threadpool.submit(()->{
		try {
			UserInfo rolesUserInfo = noSqlSource.getUserMethadata(username));
			loginConveyor.add(sessionToken,rolesUserInfo,LoginStep.USER_SETTINGS);
		} catch (Exception e) {
			loginConveyor.cancel(sessionToken);
		}
	});
	//Check AWS account
	threadpool.submit(()->{
		try {
			Boolean awsUser = amazonSource.validateAmazonUser(username);
			if (awsUser != null) {
				loginConveyor.add(sessionToken, awsUser, LoginStep.AWS_USER);
			} else {
				loginConveyor.add(sessionToken, false, LoginStep.AWS_USER);
			}
		} catch (Exception e) {
			loginConveyor.cancel(sessionToken);
		}
	});

        // Now we need to synchronize all threads we activated above with a single product future
	try {
		UserInfo userInfo = uiFuture.get(10,TimeUnit.SECONDS);
	} catch (Exception e) {
		return Response.status(Response.Status.UNAUTHORIZED).build();
	}
	return Response.ok(sessionToken).build();
}

Now lets take a look at the LoginCache implementation. As we know, it also can be implemented with the help of Caching conveyor!

In this example we will reuse same UserInfoBuilder and LoginStep. Cached values will expire after 60 minutes of inactivity. Each time the getUserInfo method is called, timeout will be extended for another 60 minutes.

public class LoginCache extends CachingConveyor<String,LoginStep,UserInfo> implements Cache {

    private final static LoginCache INSTANCE = new LoginCache();

    public static LoginCache getInstance() {
        return INSTANCE;
    }

    private LoginCache() {
        super();
        this.setName("UserInfoCache");
        this.setIdleHeartBeat(1, TimeUnit.SECONDS);
        this.setDefaultBuilderTimeout(60, TimeUnit.MINUTES);
        this.enablePostponeExpiration(true);
        this.setExpirationPostponeTime(60,TimeUnit.MINUTES);
        this.setBuilderSupplier(UserInfoBuilder::new);
        this.acceptLabels(LoginStep.USER_INFO);
    }

    public void removeUserInfo(String token) {
        this.addCommand(new CancelCommand<String>(token));
    }

    public UserInfo getUserInfo(String token) {
        Supplier<? extends UserInfo> s = this.getProductSupplier(token);
        if( s == null ) {
            return null;
        } else {
            return s.get();
        }
    }

    public void saveUserInfo(UserInfo userInfo) {
        CompletableFuture<Boolean> cacheFuture = LoginCache.getInstance().offer(userInfo.getAccessToken(),userInfo,LoginStep.USER_INFO);
        try {
            // Method get will return true when message fully processed and accepted or fail
            if(! cacheFuture.get() ) {
                throw new Exception("Offer future returned 'false' for "+userInfo);
            }
        } catch (Exception e) {
            throw new RuntimeException("User Info cache offer failure for "+userInfo,e);
        }
    }

}
⚠️ **GitHub.com Fallback** ⚠️