An Angular PWA From Front-End to Backend: Creating a Login Process
Learn how to make an Angular PWA that uses Spring Boot on the backend that can handle login processes.
Join the DZone community and get the full member experience.
Join For FreeThis is the second part of the series about the AngularPwaMessenger project. It is a chat system with offline capability. The first part showed how to to signin as a user and add users to your contacts. The users where stored on the server and the user and its contacts where stored in the indexed DB.
This part will be about the Login online and offline. The Login is discussed as a process on the client- and server-side.
The encryption of the messages will not be discusses because it adds optional complexity.
The User Record
To enable logging in online and offline, the user record has to be stored on the server and on the client. On the server. it is stored in MongoDB and on the client it is stored in the browser's indexed DB.
On the server. the user record can be found in MsgUser:
@Id
private ObjectId _id;
@JsonProperty
private Date createdAt = new Date();
@Indexed( unique=true)
@JsonProperty
private String username;
@JsonProperty
private String password;
@Indexed( unique=true)
@JsonProperty
private String email;
@JsonProperty
private String token;
@JsonProperty
private String base64Avatar;
@JsonProperty
private String publicKey;
@JsonProperty
private String privateKey;
@JsonProperty
private String userId;
@JsonProperty
private String salt;
@JsonProperty
private boolean confirmed = false;
@JsonProperty
private String uuid;
The _id
is the MongoDB id that gets generated. The username
is the unique name of the user/contact. The password
is a hashed value for the login. The email
is a unique value that can be used for the confirm email feature. The token
is the JWT token that is sent to the client for authentification. It gets filed after each successful login. The user can store a small icon as an avatar in the base64Avatar
field. The publicKey/privateKey
properties store the keys for asymetric encryption. The privateKey is stored in a wrapped format. The userId
is the property where the MongoDB key is sent to the browser. The salt
is used for the encryption of the indexed DB records. The properties confirmed
and uuid
can be used for the 'confirm email' feature.
On the browser, the user's records can be found in LocalUser:
export interface LocalUser {
id?: number;
createdAt: Date;
username: string;
hash: string;
salt: string;
email: string;
base64Avatar: string;
publicKey: string;
privateKey: string;
userId: string;
}
The id
is an auto-incrementing number. The username
is unique due to contraints on the server. The hash
is the hashed password. The salt
is used for local encryption. The base64Avatar
can store an icon for the user. The publicKey/privateKey
properties are used to encrypt/decrypt the messages. The privateKey is stored in a wrapped format. The userId
is the MongoDB ID.
The server stores the user's records so that it can be recreated if it is lost in the browser. The local user's records can be used to login if the client is offline and has the values to decrypt the messages in the index DB. Those are the user records the login is based on.
The Start of the Login Process
At the beginning of the login process, the app has to decide if an online or offline login is needed.
The login starts with the onLoginClick()
method in the login.component.ts:
onLoginClick(): void {
let myUser = new MyUser();
myUser.username = this.loginForm.get( 'username' ).value;
myUser.password = this.loginForm.get( 'password' ).value;
if ( this.connected ) {
this.cryptoService.hashServerPW( this.loginForm.get( 'password' ).value ).then( value => {
myUser.password = value;
this.authenticationService.postLogin( myUser ).subscribe( us => {
let myLocalUser: LocalUser = {
base64Avatar: null,
createdAt: null,
email: null,
hash: null,
publicKey: null,
privateKey: null,
salt: null,
username: us.username,
userId: null
};
this.localdbService.loadUser( myLocalUser )
.then( localUserList => localUserList.toArray() )
.then( localUserArray => {
if ( localUserArray.length > 0 ) {
us.password = this.loginForm.get( 'password' ).value;
this.login( us, localUserArray[0] );
} else {
this.createLocalUser( us , this.loginForm.get( 'password' ).value).then( result => {
us.password = this.loginForm.get( 'password' ).value;
this.login( us, result );
} );
}
} );
}, err => {
myUser.password = this.loginForm.get( 'password' ).value;
this.localLogin( myUser );
});
} );
} else {
this.localLogin( myUser );
}
}
In line 2, the MyUser class is created. It is the dto that is posted to the server.
In lines 3-4, the username and the password are set in the MyUser
class.
In line 5, the browser checks if it has a network connection.
In lines 6-7, the password is replaced in MyUser
with the server hash.
In line 8, we post MyUser
to the server and, if the server responds, the code continues.
In lines 9-19, the LocalUser
interface is created with the username.
In lines 20-22, the LocaldbService is called to load the user. It returns an array that has 0 or 1 user(s).
In lines 23-31, we check to see if the user was found. If yes, the MyUser
password is set back to the user password and the login method is called. If no, the createLocalUser
method is called to recreate the local user record and then the password is set back and the login is called.
In lines 33-36, the case of a server error is handled. The localLogin
method is called to use the offline mode because the server is unavailable.
In line 39, the case that the browser knows it is offline is handled. The localLogin
method is called to use the offline mode.
The Login Process on the Server-Side
The server-side login process can be found in the AuthenticationController.java file of my GitHub repo:
@PostMapping("/login")
public Mono<MsgUser> postUserLogin(@RequestBody MsgUser myUser) {
Query query = new Query();
query.addCriteria(Criteria.where("username").is(myUser.getUsername()));
if (this.confirmUrl != null && !this.confirmUrl.isBlank()) {
query.addCriteria(Criteria.where("confirmed").is(true));
}
return this.operations.findOne(query, MsgUser.class).switchIfEmpty(Mono.just(new MsgUser()))
.map(user1 -> {
if(user1.get_id() != null) user1.setUserId(user1.get_id().toString());
return user1;
}).map(user1 -> loginHelp(user1, myUser.getPassword())).onErrorResume(re -> {
LOG.info("login failed for: "+myUser.getUsername(),re);
return Mono.just(new MsgUser());
});
}
private MsgUser loginHelp(MsgUser user, String passwd) {
if (user.getUsername() != null) {
if (this.passwordEncoder.matches(passwd, user.getPassword())) {
String jwtToken = this.jwtTokenProvider.createToken(user.getUsername(),
Arrays.asList(Role.USERS), Optional.empty());
user.setToken(jwtToken);
user.setPassword("XXX");
return user;
}
}
return new MsgUser();
}
In lines 1-2, the REST endpoint gets created with the annotation and the mapping of the request body in the dto.
In lines 3-4, the MongoDB query is built. It searches for a matching username.
In lines 5-7, the the confirm email feature is handled.
In line 8, the query is sent to MongoDB and if the user is not found an empty dto is returned.
In lines 9-11, the MongoDB ID is mapped in the UserId
property.
In lines 12-15, the loginHelp
method is called with the supplied password and onErrorResume
returns an empty dto if it gets called.
In line 19, the username is checked if it is found the user exists.
In line 20, the password is checked against he hash in MongoDB.
In lines 21-25, the JWT Token is created. It is set in the dto and dto and the password hash is removed in the dto.
The Login Without the Local User
Without the local user in the indexed DB the user record is recreated of the server dto. That is done in the createLocalUser
method.
private createLocalUser( us: MyUser, passwd: string): PromiseLike<LocalUser> {
let localUser: LocalUser = null;
return this.cryptoService.generateKey( passwd, us.salt ? us.salt : null )
.then( ( result ) => {
localUser = {
base64Avatar: us.base64Avatar,
createdAt: us.createdAt,
email: us.email,
hash: result.a,
salt: result.b,
username: us.username,
publicKey: us.publicKey,
privateKey: us.privateKey,
userId: us.userId
};
return localUser;
} ).then( myLocalUser => this.localdbService.storeUser( myLocalUser ) )
.then( value => Promise.resolve( value ) ).then( () => localUser );
}
In line 1, the method is created with the MyUser
dto that comes from the server and the password the user has provided.
In line 3, generateKey
is called to recreate the local key based of the salt in the MyUser
dto from the server.
In lines 4-16, the localUser
dto is populated.
In line 17, the localUser
is stored in the indexed DB with the localdbService
.
In line 18, Promise.resolve(...)
is called to wait until the localUser
is persisted and then a Promise with the localUser
is returned.
The Local Login
In case of a missing network or a missing server response localLogin
is called:
private localLogin( myUser: MyUser ) {
let myLocalUser: LocalUser = {
base64Avatar: null,
createdAt: null,
email: null,
hash: null,
publicKey: null,
privateKey: null,
salt: null,
username: myUser.username,
userId: null
};
this.localdbService.loadUser( myLocalUser ).then( localUserList =>
localUserList.first().then( myLocalUser => {
myUser.userId = myLocalUser.userId;
return this.login( myUser, myLocalUser );
}) );
}
In lines 2-12, the LocalUser
dto is created with the unique username that the user provided.
In lines 13-14, the LocaldbService
loads the the user record from the indexed DB.
In lines 15-16, the userId
is set in the myUser
dto and the login method is called.
The user record in the indexed DB has the salt and the hash to do the login but without the JWT token it is impossible to query the server.
Finally, the Login
The login method is called for the local and the remote login.
login( us: MyUser, localUser: LocalUser ): void {
this.cryptoService.generateKey( us.password, localUser.salt ).then( tuple => {
if ( ( us.username !== null || localUser.username !== null ) && localUser.hash === tuple.a ) {
if ( us.token ) {
this.jwttokenService.jwtToken = us.token;
}
this.loginFailed = false;
this.cryptoService.hashPW( us.password ).then( value => {
us.password = value;
us.salt = localUser.salt;
this.data.myUser = us;
this.dialogRef.close( this.data.myUser );
} );
} else {
this.loginFailed = true;
}
} );
}
In line 1, the login method is created and the MyUser
parameter has the username and the user provided password. The LocalUser
parameter has the indexed DB user record.
In line 2, the the hash for the of the password is created. It needs the password and the salt of the LocalUser
record.
In line 3, the it is checked that the local username and the provided username exist and the created hash is checked against the hash in LocalUser
record. If these checks are successful the user is logged in.
In lines 4-6, it is checked if the server has provided a JWT token. That means it is a successful online login. Then the token is set in the JwtTokenService
. Without this token, it is not possible to get contacts or messages from the server.
In line 7, the UI flag for a successful login is is set.
In lines 8-13, the password is hashed by the hashPW
function and set in the MyUser
dto. The salt is set in the MyUser
dto and the login dialog is closed.
In line 15, the UI flag is set to show the login failed message in the login dialog.
Conclusion
This is the login process of the AngularPwaMessenger. To make the process work locally, the salt and the password hash need to be stored in the indexed DB and that user record is used. The server provides the JWT token for authentication of the REST calls to the server. If the JWT token is unavailable, the PWA is offline and the local login with the indexed DB is used. The password is neither stored or sent somewhere. To save the user from losing the keys the public/private (wrapped) keys are stored on the server; the salt for the indexed DB is stored too. That makes it possible to recreate the local user record from the server.
This makes it possible to provide a login for an Angular PWA. With the help of Dexie and Angular, this amount of code is sufficiant for the login process. In the next part of the series, the sending and receiving of messages will be discussed.
Opinions expressed by DZone contributors are their own.
Comments