Ltpa2Token.java

/*
 * Copyright 2018 Ronny "Sephiroth" Perinke <[email protected]>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package de.sephirothj.spring.security.ltpa2;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.regex.Pattern;
import lombok.Getter;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

/**
 * Represents an LTPA2-Token
 *
 * @author Sephiroth
 */
public class Ltpa2Token
{
	/**
	 * the well-known name for the attribute containing the username
	 */
	public static final String USER_ATTRIBUTE_NAME = "u";

	/**
	 * the well-known name for the attribute containing the expiration
	 */
	public static final String EXPIRE_ATTRIBUTE_NAME = "expire";

	private static final char BODY_PARTS_DELIMITER = '$';
	private static final String BODY_KEY_VALUE_DELIMITER = ":";
	private static final Pattern BODY_PARTS_PATTERN = Pattern.compile("\\$");

	/**
	 * Expiration of the token
	 */
	@Getter
	private LocalDateTime expire;

	/**
	 * user DN
	 */
	@Getter
	private String user;

	private final HashMap<String, String> additionalAttributes = new HashMap<>();

	/**
	 * sets the expiration of the token
	 *
	 * @param expire the (new) expiration date
	 */
	public void setExpire(@NonNull final LocalDateTime expire)
	{
		Assert.notNull(expire, "expire must not be null");
		this.expire = expire;
	}

	/**
	 * sets the expiration of the token
	 *
	 * @param expire the (new) expiration date in milliseconds since 01.01.1970T00:00:00Z
	 */
	public void setExpire(@NonNull final String expire)
	{
		Assert.hasText(expire, "expire must not be empty");
		this.expire = LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(expire)), ZoneId.systemDefault());
	}

	/**
	 * checks if this token is expired
	 *
	 * @return whether the token is expired or not
	 */
	public boolean isExpired()
	{
		return getExpire().isBefore(LocalDateTime.now());
	}

	/**
	 * sets the user of the token
	 *
	 * @param user the (new) user DN
	 */
	public void setUser(@NonNull final String user)
	{
		Assert.hasText(user, "user must not be empty");
		this.user = user;
	}

	/**
	 * <p>
	 * get the value of the specified attribute</p>
	 * <p>
	 * known attributes:</p>
	 * <ul>
	 * <li>u: same as {@link #user}</li>
	 * <li>expire: {@link #expire}</li>
	 * <li>host</li>
	 * <li>port</li>
	 * <li>java.naming.provider.url</li>
	 * <li>process.serverName</li>
	 * <li>security.authMechOID</li>
	 * <li>type</li>
	 * </ul>
	 *
	 * @param attribute the name of the attribute
	 * @return attribute value. may be {@code null}
	 */
	@Nullable
	public String getAttribute(@NonNull final String attribute)
	{
		Assert.notNull(attribute, "attribute must not be null");

		return switch (attribute)
		{
			case USER_ATTRIBUTE_NAME -> user;
			case EXPIRE_ATTRIBUTE_NAME -> expire != null ? String.valueOf(expire.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()) : null;
			default -> additionalAttributes.get(attribute);
		};
	}

	/**
	 * sets an attribute
	 *
	 * @param attribute the name of the attribute
	 * @param value attribute value
	 * @return this instance for chaining
	 */
	@NonNull
	public Ltpa2Token withAttribute(@NonNull final String attribute, @NonNull final String value)
	{
		Assert.notNull(attribute, "attribute must not be null");
		Assert.hasText(value, "value must not be empty");

		switch (attribute)
		{
			case USER_ATTRIBUTE_NAME -> setUser(value);
			case EXPIRE_ATTRIBUTE_NAME -> setExpire(value);
			default -> additionalAttributes.put(attribute, value);
		}
		return this;
	}

	/**
	 * gets the token as serialized (tokenized) string
	 *
	 * @return the serialized token
	 */
	@Override
	public String toString()
	{
		final StringBuilder sb = new StringBuilder();
		if (expire != null)
		{
			sb.append(EXPIRE_ATTRIBUTE_NAME).append(BODY_KEY_VALUE_DELIMITER).append(getAttribute(EXPIRE_ATTRIBUTE_NAME)).append(BODY_PARTS_DELIMITER);
		}
		additionalAttributes.forEach((key, value) -> sb.append(key).append(BODY_KEY_VALUE_DELIMITER).append(escapeValue(value)).append(BODY_PARTS_DELIMITER));
		sb.append(USER_ATTRIBUTE_NAME).append(BODY_KEY_VALUE_DELIMITER).append(escapeValue(user));
		return sb.toString();
	}

	/**
	 * creates a new instance out of serialized (tokenized) token
	 *
	 * @param serializedToken a serialized LTPA2 token (unencrypted)
	 * @return new instance
	 * @throws IllegalArgumentException if the given token is empty
	 */
	@NonNull
	public static Ltpa2Token of(@NonNull final String serializedToken)
	{
		Assert.hasText(serializedToken, "serializedToken must not be empty");

		final Ltpa2Token token = new Ltpa2Token();
		BODY_PARTS_PATTERN.splitAsStream(serializedToken).forEach(part ->
		{
			final String[] nameValue = part.split(BODY_KEY_VALUE_DELIMITER, 2);
			token.withAttribute(nameValue[0], unescapeValue(nameValue[1]));
		});
		return token;
	}

	/**
	 * escapes the special chars {@code :}, {@code $} and {@code % } with a preceding {@code \} in the given value
	 *
	 * @param value the value to escaped
	 * @return the value with special chars escaped
	 */
	@NonNull
	private static String escapeValue(@NonNull final String value)
	{
		return value.replaceAll("([\\:\\$\\%])", "\\\\$1");
	}

	/**
	 * unescape special chars in the given value
	 *
	 * @param value the value to unescape
	 * @return the value with special chars unescaped
	 * @see #escapeValue(java.lang.String)
	 */
	@NonNull
	private static String unescapeValue(@NonNull final String value)
	{
		return value.replaceAll("\\\\([\\:\\$\\%])", "$1");
	}
}