org.haiku.haikudepotserver.security.AuthenticationServiceImpl.java Source code

Java tutorial

Introduction

Here is the source code for org.haiku.haikudepotserver.security.AuthenticationServiceImpl.java

Source

/*
 * Copyright 2018, Andrew Lindesay
 * Distributed under the terms of the MIT License.
 */

package org.haiku.haikudepotserver.security;

import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.io.BaseEncoding;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.apache.cayenne.ObjectContext;
import org.apache.cayenne.ObjectId;
import org.apache.cayenne.configuration.server.ServerRuntime;
import org.apache.commons.lang3.StringUtils;
import org.haiku.haikudepotserver.dataobjects.User;
import org.haiku.haikudepotserver.security.model.AuthenticationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.security.MessageDigest;
import java.text.ParseException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;

@Service
public class AuthenticationServiceImpl implements AuthenticationService {

    protected static Logger LOGGER = LoggerFactory.getLogger(AuthenticationServiceImpl.class);

    private final static String SUFFIX_JSONWEBTOKEN_SUBJECT = "@hds";

    private final static Pattern PATTERN_JSONWEBTOKEN_ISSUER = Pattern.compile("^[a-z0-9]+\\.hds$");

    private final ServerRuntime serverRuntime;

    /**
     * <p>This secret is used to sign a token containing username / password / time for token-based authentication.
     * </p>
     */

    private final Integer jsonWebTokenExpirySeconds;
    private final String jsonWebTokenIssuer;

    private JWSSigner jsonWebTokenSigner = null;
    private JWSVerifier jsonWebTokenVerifier = null;
    private String jsonWebTokenSharedKey;

    public AuthenticationServiceImpl(ServerRuntime serverRuntime,
            @Value("${authentication.jws.sharedkey:}") String jsonWebTokenSharedKey,
            @Value("${authentication.jws.expiryseconds:300}") Integer jsonWebTokenExpirySeconds,
            @Value("${authentication.jws.issuer}") String jsonWebTokenIssuer) {
        this.serverRuntime = Preconditions.checkNotNull(serverRuntime);
        this.jsonWebTokenExpirySeconds = Preconditions.checkNotNull(jsonWebTokenExpirySeconds);
        this.jsonWebTokenIssuer = Preconditions.checkNotNull(jsonWebTokenIssuer);
        this.jsonWebTokenSharedKey = StringUtils.isNotBlank(jsonWebTokenSharedKey) ? jsonWebTokenSharedKey
                : UUID.randomUUID().toString();
    }

    @PostConstruct
    public void init() {

        if (jsonWebTokenExpirySeconds <= 15) {
            throw new IllegalStateException("the expiry seconds for the json web token is too small");
        }

        if (Strings.isNullOrEmpty(jsonWebTokenIssuer)
                || !PATTERN_JSONWEBTOKEN_ISSUER.matcher(jsonWebTokenIssuer).matches()) {
            throw new IllegalStateException("the json web token issuer is malformed");
        }

        if (!Strings.isNullOrEmpty(jsonWebTokenSharedKey)) {
            jsonWebTokenSharedKey = jsonWebTokenSharedKey.trim();

            if (jsonWebTokenSharedKey.length() < 10) {
                throw new IllegalStateException("the json web token shared key length is too small to be secure");
            }
        }

        if (Strings.isNullOrEmpty(jsonWebTokenSharedKey)) {
            jsonWebTokenSharedKey = UUID.randomUUID().toString();
            LOGGER.warn("a shared key is not supplied so a random one has been created");
        }

        try {
            jsonWebTokenSigner = new MACSigner(jsonWebTokenSharedKey.getBytes(Charsets.UTF_8));
            jsonWebTokenVerifier = new MACVerifier(jsonWebTokenSharedKey.getBytes(Charsets.UTF_8));
        } catch (JOSEException je) {
            throw new RuntimeException("unable to create the signer / verifier for JWT tokens", je);
        }
    }

    @Override
    public Optional<ObjectId> authenticateByNicknameAndPassword(String nickname, String passwordClear) {

        Optional<ObjectId> result = Optional.empty();

        if (!Strings.isNullOrEmpty(nickname) && !Strings.isNullOrEmpty(passwordClear)) {

            ObjectContext objectContext = serverRuntime.newContext();

            Optional<User> userOptional = User.tryGetByNickname(objectContext, nickname);

            if (userOptional.isPresent()) {
                User user = userOptional.get();
                String hash = hashPassword(user, passwordClear);

                if (hash.equals(user.getPasswordHash())) {
                    result = Optional.ofNullable(userOptional.get().getObjectId());
                } else {
                    LOGGER.info("the authentication for the user; {} failed", nickname);
                }
            } else {
                LOGGER.info("unable to find the user; {}", nickname);
            }
        } else {
            LOGGER.info("attempt to authenticate with no username or no password");
        }

        return result;
    }

    /**
     * <p>This method will hash the password in a consistent manner across the whole system.</p>
     */

    @Override
    public String hashPassword(User user, String passwordClear) {
        byte[] saltBytes = BaseEncoding.base16().decode(user.getPasswordSalt().toUpperCase());

        MessageDigest sha;
        try {
            sha = MessageDigest.getInstance("SHA-256");
            sha.update(passwordClear.getBytes(Charsets.UTF_8));
        } catch (java.security.NoSuchAlgorithmException e) {
            throw new IllegalStateException("no SHA-256 crypt algorithm available", e);
        }

        sha.update(saltBytes);

        return BaseEncoding.base16().encode(sha.digest()).toLowerCase();
    }

    private int countMatches(String s, CharToBooleanFunction fn) {
        int length = s.length();
        int count = 0;
        for (int i = 0; i < length; i++) {
            char c = s.charAt(i);
            if (fn.test(c)) {
                count++;
            }
        }
        return count;
    }

    /**
     * <p>Passwords should be hard to guess and so there needs to be a certain level of complexity to
     * them.  They should of a certain length and should contain some mix of letters and digits as well
     * as at least one upper case letter.</p>
     *
     * <p>This method will check the password for suitability.</p>
     */

    @Override
    public boolean validatePassword(String passwordClear) {
        Preconditions.checkArgument(null != passwordClear, "the password clear must be supplied");

        if (passwordClear.length() < 8) {
            return false;
        }

        // get a count of digits - should be at least two.
        if (countMatches(passwordClear, c -> c >= 48 && c <= 57) < 2) {
            return false;
        }

        // get a count of upper case letters - should be at least one.
        if (countMatches(passwordClear, c -> c >= 65 && c <= 90) < 1) {
            return false;
        }

        return true;
    }

    private interface CharToBooleanFunction {
        boolean test(char c);
    }

    // ---------------------------
    // JSON WEB TOKEN

    @Override
    public Optional<ObjectId> authenticateByToken(String payload) {
        Optional<SignedJWT> signedJwtOptional = verifyToken(payload);

        if (signedJwtOptional.isPresent()) {
            return authenticate(signedJwtOptional.get());
        }

        return Optional.empty();
    }

    /**
     * <p>This method will validate the json web token and assuming that everything is OK, it will return
     * an ObjectId that refers to the </p>
     */

    private Optional<ObjectId> authenticate(SignedJWT signedJwt) {

        Preconditions.checkArgument(null != signedJwt, "the JWT must be provided");

        JWTClaimsSet claimsSet;
        long nowMillis = System.currentTimeMillis();

        try {
            claimsSet = signedJwt.getJWTClaimsSet();
        } catch (ParseException pe) {
            throw new IllegalStateException("unable to parse the jwt", pe);
        }

        String issuer = claimsSet.getIssuer();

        if (null == issuer || !issuer.equals(jsonWebTokenIssuer)) {
            LOGGER.info("rejected jwt authentication; the issuer '{}' on the jwt does not match the expected '{}'",
                    issuer, jsonWebTokenIssuer);
        } else {
            java.util.Date issueTime = claimsSet.getIssueTime();
            java.util.Date expirationTime = claimsSet.getExpirationTime();

            if (null == issueTime || null == expirationTime || nowMillis < issueTime.getTime()
                    || nowMillis > expirationTime.getTime()) {
                LOGGER.info(
                        "rejected jwt authentication; the issue time or expiration time are invalid or do not contain the current time");
            } else {
                String subject = claimsSet.getSubject();

                if (null == subject || !subject.endsWith(SUFFIX_JSONWEBTOKEN_SUBJECT)
                        || subject.length() <= SUFFIX_JSONWEBTOKEN_SUBJECT.length()) {
                    LOGGER.info("rejected jwt authentication; bad subject");
                } else {

                    String nickname = subject.substring(0, subject.length() - SUFFIX_JSONWEBTOKEN_SUBJECT.length());
                    ObjectContext context = serverRuntime.newContext();
                    Optional<User> userOptional = User.tryGetByNickname(context, nickname);

                    if (userOptional.isPresent()) {
                        return Optional.of(userOptional.get().getObjectId());
                    }
                }
            }
        }

        return Optional.empty();
    }

    /**
     * <p>This method will take a JWT payload and will check the signing.  If the signing and format
     * of the payload is OK then it will return the payload parsed; otherwise it will return
     * an absent.</p>
     */

    private Optional<SignedJWT> verifyToken(String payload) {
        if (null != payload && 0 != payload.length()) {
            try {
                SignedJWT signedJWT = SignedJWT.parse(payload);

                try {
                    if (signedJWT.verify(jsonWebTokenVerifier)) {
                        return Optional.of(signedJWT);
                    } else {
                        LOGGER.error("attempt to use jwt that was unable to be verified");
                    }
                } catch (JOSEException je) {
                    throw new IllegalStateException("unable to verify the jwt", je);
                }
            } catch (ParseException pe) {
                LOGGER.error("rejected malformed jwt that was unable to be parsed", pe);
            }
        }

        return Optional.empty();
    }

    /**
     * <p>This will return a JWT (java web token) that is signed by a secret that allows for the client to get
     * that token and for it to be used as a form of authentication for some period of time.</p>
     */

    @Override
    public String generateToken(User user) {

        Preconditions.checkArgument(null != user, "the user must be provided");

        Instant instant = Instant.now();

        JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
                .subject(user.getNickname() + SUFFIX_JSONWEBTOKEN_SUBJECT)
                .issueTime(new java.util.Date(instant.toEpochMilli()))
                .expirationTime(new java.util.Date(
                        instant.plus(jsonWebTokenExpirySeconds, ChronoUnit.SECONDS).toEpochMilli()))
                .issuer(jsonWebTokenIssuer).build();

        SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet);

        try {
            signedJWT.sign(jsonWebTokenSigner);
        } catch (JOSEException je) {
            throw new IllegalStateException("unable to sign a jwt", je);
        }

        return signedJWT.serialize();
    }

}