Java/springboot service third-party interface security signature (Signature) implementation plan

Foreword

Sometimes, we need to open the interfaces in our system to third-party applications or enterprises. Then the third-party system is not within our own authentication and authorized user system. At this time, how to ensure the data security and identity of our interfaces? What about identification?

When providing an interface for a third-party system, you must consider the security of the interface data, such as whether the data has been tampered with, whether the data is out of date, and whether the data can be submitted repeatedly. Among them, I think the final thing is whether the data has been tampered with.

A common practice in the industry is to assign appId and appSecret to third-party applications, and then perform interface signature verification.

Signature process

Signature rules

1. Allocate appid and appsecret offline, and allocate different appid and appsecret to different callers.

2. Add timestamp (timestamp), the data will be valid within 10 minutes

3. Add a serial number nonce (to prevent repeated submissions), which must be at least 10 digits. For the query interface, the serial number is only used for log implementation to facilitate later log verification. For management interfaces, it is necessary to verify the uniqueness of the serial number within the validity period to avoid repeated requests.

4. Add signature, the signature information of all data.

The above red fields are placed in the request header.

Signature generation

The signature field generation rules are as follows.

Data part

Path: Splice all values in the order in the path

?Query: Sort according to key dictionary order and splice all key=value

?Form: Sort according to key dictionary order, and splice all key=value

?Body

Json: Sort according to key dictionary order, and concatenate all key=value (for example {“a”:”a”,”c”:”c”,”b”:{\ “e”:”e”}} => a=ab=e=ec=c)

String: The entire string is concatenated as a

If there are multiple data forms, the splicing is performed in the order of path, query, form, and body to obtain the spliced value of all data.

The value of the above splicing is recorded as Y.

Request header part

X=”appid=xxxnonce=xxxtimestamp=xxx”

Generate signature

Final splicing value = XY

Finally, the final spliced value is encrypted as follows to obtain the signature.

?signature=org.apache.commons.codec.digest.HmacUtils.hmacSha256Hex(app secret, spliced value);

Signature algorithm implementation

Specify which interfaces or entities need to be signed

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({TYPE, METHOD})
@Retention(RUNTIME)
@Documented
public @interface Signature {
    String ORDER_SORT = "ORDER_SORT";//Sort by order value
    String ALPHA_SORT = "ALPHA_SORT";//lexicographic order
    boolean resubmit() default true;//Allow repeated requests
    String sort() default Signature.ALPHA_SORT;
}

Specify which fields need to be signed

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({FIELD})
@Retention(RUNTIME)
@Documented
public @interface SignatureField {
    //Signature order
    int order() default 0;

    //Field name custom value
    String customName() default "";

    //Field value custom value
    String customValue() default "";
}

Core algorithm

/**
 * Generate all splices marked with SignatureField attribute key=value
 */
public static String toSplice(Object object) {
    if (Objects.isNull(object)) {
        return StringUtils.EMPTY;
    }
    if (isAnnotated(object.getClass(), Signature.class)) {
        Signature sg = findAnnotation(object.getClass(), Signature.class);
        switch (sg.sort()) {
            case Signature.ALPHA_SORT:
                return alphaSignature(object);
            case Signature.ORDER_SORT:
                return orderSignature(object);
            default:
                return alphaSignature(object);
        }
    }
    return toString(object);
}

private static String alphaSignature(Object object) {
    StringBuilder result = new StringBuilder();
    Map<String, String> map = new TreeMap<>();
    for (Field field : getAllFields(object.getClass())) {
        if (field.isAnnotationPresent(SignatureField.class)) {
            field.setAccessible(true);
            try {
                if (isAnnotated(field.getType(), Signature.class)) {
                    if (!Objects.isNull(field.get(object))) {
                        map.put(field.getName(), toSplice(field.get(object)));
                    }
                } else {
                    SignatureField sgf = field.getAnnotation(SignatureField.class);
                    if (StringUtils.isNotEmpty(sgf.customValue()) || !Objects.isNull(field.get(object))) {
                        map.put(StringUtils.isNotBlank(sgf.customName()) ? sgf.customName() : field.getName()
                                , StringUtils.isNotEmpty(sgf.customValue()) ? sgf.customValue() : toString(field.get(object)));
                    }
                }
            } catch (Exception e) {
                LOGGER.error("Signature splicing (alphaSignature) exception", e);
            }
        }
    }

    for (Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator(); iterator.hasNext(); ) {
        Map.Entry<String, String> entry = iterator.next();
        result.append(entry.getKey()).append("=").append(entry.getValue());
        if (iterator.hasNext()) {
            result.append(DELIMETER);
        }
    }
    return result.toString();
}

/**
 * Processing for array, collection, simple property, map
 */
private static String toString(Object object) {
    Class<?> type = object.getClass();
    if (BeanUtils.isSimpleProperty(type)) {
        return object.toString();
    }
    if (type.isArray()) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < Array.getLength(object); + + i) {
            sb.append(toSplice(Array.get(object, i)));
        }
        return sb.toString();
    }
    if (ClassUtils.isAssignable(Collection.class, type)) {
        StringBuilder sb = new StringBuilder();
        for (Iterator<?> iterator = ((Collection<?>) object).iterator(); iterator.hasNext(); ) {
            sb.append(toSplice(iterator.next()));
            if (iterator.hasNext()) {
                sb.append(DELIMETER);
            }
        }
        return sb.toString();
    }
    if (ClassUtils.isAssignable(Map.class, type)) {
        StringBuilder sb = new StringBuilder();
        for (Iterator<? extends Map.Entry<String, ?>> iterator = ((Map<String, ?>) object).entrySet().iterator(); iterator.hasNext(); ) {
            Map.Entry<String, ?> entry = iterator.next();
            if (Objects.isNull(entry.getValue())) {
                continue;
            }
            sb.append(entry.getKey()).append("=").append(toSplice(entry.getValue()));
            if (iterator.hasNext()) {
                sb.append(DELIMETER);
            }
        }
        return sb.toString();
    }
    return NOT_FOUND;
}

Verification of signature

The parameters in the header are as follows

?

Signature entity

import com.google.common.base.MoreObjects;
import com.google.common.collect.Sets;
import org.hibernate.validator.constraints.NotBlank;

import java.util.Set;

@ConfigurationProperties(prefix = "wmhopenapi.validate", exceptionIfInvalid = false)
@Signature
public class SignatureHeaders {
    public static final String SIGNATURE_HEADERS_PREFIX = "wmhopenapi-validate";

    public static final Set<String> HEADER_NAME_SET = Sets.newHashSet();

    private static final String HEADER_APPID = SIGNATURE_HEADERS_PREFIX + "-appid";
    private static final String HEADER_TIMESTAMP = SIGNATURE_HEADERS_PREFIX + "-timestamp";
    private static final String HEADER_NONCE = SIGNATURE_HEADERS_PREFIX + "-nonce";
    private static final String HEADER_SIGNATURE = SIGNATURE_HEADERS_PREFIX + "-signature";

    static {
        HEADER_NAME_SET.add(HEADER_APPID);
        HEADER_NAME_SET.add(HEADER_TIMESTAMP);
        HEADER_NAME_SET.add(HEADER_NONCE);
        HEADER_NAME_SET.add(HEADER_SIGNATURE);
    }

    /**
     * Value assigned offline
     * The client and server each save the appSecret corresponding to the appId.
     */
    @NotBlank(message = "Missing in Header" + HEADER_APPID)
    @SignatureField
    private String appid;
    /**
     * Value assigned offline
     * The client and server are saved separately, corresponding to the appId
     */
    @SignatureField
    private String appsecret;
    /**
     * Timestamp, unit: ms
     */
    @NotBlank(message = "Missing in Header" + HEADER_TIMESTAMP)
    @SignatureField
    private String timestamp;
    /**
     * Serial number [prevent repeated submission]; (Note: For the query interface, the serial number is only used for log implementation to facilitate later log verification; for the management interface, the uniqueness of the serial number within the validity period needs to be verified to avoid repeated requests)
     */
    @NotBlank(message = "Missing in Header" + HEADER_NONCE)
    @SignatureField
    private String nonce;
    /**
     * sign
     */
    @NotBlank(message = "Missing in Header" + HEADER_SIGNATURE)
    private String signature;

    public String getAppid() {
        return appid;
    }

    public void setAppid(String appid) {
        this.appid = appid;
    }

    public String getAppsecret() {
        return appsecret;
    }

    public void setAppsecret(String appsecret) {
        this.appsecret = appsecret;
    }

    public String getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(String timestamp) {
        this.timestamp = timestamp;
    }

    public String getNonce() {
        return nonce;
    }

    public void setNonce(String nonce) {
        this.nonce = nonce;
    }

    public String getSignature() {
        return signature;
    }

    public void setSignature(String signature) {
        this.signature = signature;
    }

    @Override
    public String toString() {
        return MoreObjects.toStringHelper(this)
                .add("appid", appid)
                .add("appsecret", appsecret)
                .add("timestamp", timestamp)
                .add("nonce", nonce)
                .add("signature", signature)
                .toString();
    }
}

Generate SignatureHeaders entities based on the header value in the request

private SignatureHeaders genrateSignatureHeaders(Signature signature, HttpServletRequest request) throws Exception {//NOSONAR
    Map<String, Object> headerMap = Collections.list(request.getHeaderNames())
            .stream()
            .filter(headerName -> SignatureHeaders.HEADER_NAME_SET.contains(headerName))
            .collect(Collectors.toMap(headerName -> headerName.replaceAll("-", "."), headerName -> request.getHeader(headerName)));
    PropertySource propertySource = new MapPropertySource("signatureHeaders", headerMap);
    SignatureHeaders signatureHeaders = RelaxedConfigurationBinder.with(SignatureHeaders.class)
            .setPropertySources(propertySource)
            .doBind();
    Optional<String> result = ValidatorUtils.validateResultProcess(signatureHeaders);
    if (result.isPresent()) {
        throw new ServiceException("WMH5000", result.get());
    }
    //Get the appsecret corresponding to the appid from the configuration
    String appSecret = limitConstants.getSignatureLimit().get(signatureHeaders.getAppid());
    if (StringUtils.isBlank(appSecret)) {
        LOGGER.error("The appSecret corresponding to appId was not found, appId=" + signatureHeaders.getAppid());
        throw new ServiceException("WMH5002");
    }

    //Other legality checks
    Long now = System.currentTimeMillis();
    Long requestTimestamp = Long.parseLong(signatureHeaders.getTimestamp());
    if ((now - requestTimestamp) > EXPIRE_TIME) {
        String errMsg = "The request time exceeds the specified range by 10 minutes, signature=" + signatureHeaders.getSignature();
        LOGGER.error(errMsg);
        throw new ServiceException("WMH5000", errMsg);
    }
    String nonce = signatureHeaders.getNonce();
    if (nonce.length() < 10) {
        String errMsg = "The length of the random string nonce must be at least 10 digits, nonce=" + nonce;
        LOGGER.error(errMsg);
        throw new ServiceException("WMH5000", errMsg);
    }
    if (!signature.resubmit()) {
        String existNonce = redisCacheService.getString(nonce);
        if (StringUtils.isBlank(existNonce)) {
            redisCacheService.setString(nonce, nonce);
            redisCacheService.expire(nonce, (int) TimeUnit.MILLISECONDS.toSeconds(RESUBMIT_DURATION));
        } else {
            String errMsg = "Duplicate requests are not allowed, nonce=" + nonce;
            LOGGER.error(errMsg);
            throw new ServiceException("WMH5000", errMsg);
        }
    }
//Set appsecret
    signatureHeaders.setAppsecret(appSecret);
    return signatureHeaders;
}

There are several steps required before generating a signature, as follows.

(1) Is the appid legal?

(2) Get the appsecret from the configuration center based on the appid

(3) Whether the request has expired, the default is 10 minutes

(4) Is the random string legal?

(5) Whether to allow repeated requests

Generate header information parameter splicing

String headersToSplice = SignatureUtils.toSplice(signatureHeaders);

Generate parameters in header and splice parameters in mehtod

private List<String> generateAllSplice(Method method, Object[] args, String headersToSplice) {
    List<String> pathVariables = Lists.newArrayList(), requestParams = Lists.newArrayList();
    String beanParams = StringUtils.EMPTY;
    for (int i = 0; i < method.getParameterCount(); + + i) {
        MethodParameter mp = new MethodParameter(method, i);
        boolean findSignature = false;
        for (Annotation anno : mp.getParameterAnnotations()) {
            if (anno instanceof PathVariable) {
                if (!Objects.isNull(args[i])) {
                    pathVariables.add(args[i].toString());
                }
                findSignature = true;
            } else if (anno instanceof RequestParam) {
                RequestParam rp = (RequestParam) anno;
                String name = mp.getParameterName();
                if (StringUtils.isNotBlank(rp.name())) {
                    name = rp.name();
                }
                if (!Objects.isNull(args[i])) {
                    List<String> values = Lists.newArrayList();
                    if (args[i].getClass().isArray()) {
                        //array
                        for (int j = 0; j < Array.getLength(args[i]); + + j) {
                            values.add(Array.get(args[i], j).toString());
                        }
                    } else if (ClassUtils.isAssignable(Collection.class, args[i].getClass())) {
                        //gather
                        for (Object o : (Collection<?>) args[i]) {
                            values.add(o.toString());
                        }
                    } else {
                        //single value
                        values.add(args[i].toString());
                    }
                    values.sort(Comparator.naturalOrder());
                    requestParams.add(name + "=" + StringUtils.join(values));
                }
                findSignature = true;
            } else if (anno instanceof RequestBody || anno instanceof ModelAttribute) {
                beanParams = SignatureUtils.toSplice(args[i]);
                findSignature = true;
            }

            if (findSignature) {
                break;
            }
        }
        if (!findSignature) {
            LOGGER.info(String.format("Unrecognized annotation with signature, method=%s, parameter=%s, annotations=%s", method.getName(), mp.getParameterName(), StringUtils.join(mp .getMethodAnnotations())));
        }
    }
    List<String> toSplices = Lists.newArrayList();
    toSplices.add(headersToSplice);
    toSplices.addAll(pathVariables);
    requestParams.sort(Comparator.naturalOrder());
    toSplices.addAll(requestParams);
    toSplices.add(beanParams);
    return toSplices;
}

Regenerate signature information for the final splicing result

SignatureUtils.signature(allSplice.toArray(new String[]{}), signatureHeaders.getAppsecret());

Depends on third-party toolkits

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-beans</artifactId>
</dependency>

Usage examples

Generate signature

//Initialize request header information
SignatureHeaders signatureHeaders = new SignatureHeaders();
signatureHeaders.setAppid("111");
signatureHeaders.setAppsecret("222");
signatureHeaders.setNonce(SignatureUtils.generateNonce());
signatureHeaders.setTimestamp(String.valueOf(System.currentTimeMillis()));
List<String> pathParams = new ArrayList<>();
//Initialize the data in path
pathParams.add(SignatureUtils.encode("18237172801", signatureHeaders.getAppsecret()));
//Call the signature tool to generate a signature
signatureHeaders.setSignature(SignatureUtils.signature(signatureHeaders, pathParams, null, null));
System.out.println("Signature data: " + signatureHeaders);
System.out.println("Request data: " + pathParams);

Output results

Splicing result: appid=111^_^appsecret=222^_^nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67^_^timestamp=1538207443910^_^w8rAwcXDxcD KwsM=^_^
Signature data: SignatureHeaders{appid=111, appsecret=222, timestamp=1538207443910, nonce=c9e778ba668c8f6fedf35634eb493af6304d54392d990262d9e7c1960b475b67, signature=0a7d0b5e802eb5e 52ac0cfcd6311b0faba6e2503a9a8d1e2364b38617877574d}
Request data: [w8rAwcXDxcDKwsM=]

Introducing HmacSHA1

HmacSHA1 is an implementation based on the Hash Message Authentication Code (HMAC) algorithm. HMAC is a key-dependent hash algorithm used to verify message integrity and authentication.

HmacSHA1 combines the use of the SHA-1 hash function and a key to generate a message authentication code (MAC) with a fixed-length output. This MAC can be used to verify whether the data has been tampered with or impersonated during transmission.

The workflow of the HmacSHA1 algorithm is as follows:

  1. Prepare message and key: Prepare the message and key for authentication.

  2. Key padding: If the key length is less than 64 bytes, pad it to 64 bytes; if the key length is greater than 64 bytes, use SHA-1 hashing to shorten it to 64 bytes.

  3. Internal secret hash: XOR each key byte with 0x36, then SHA-1 hash with the message.

  4. Final hash: Process the hash result obtained in step 3 with the original key again. XOR each key byte with 0x5C and then SHA-1 hash it with the hash result from step 3.

  5. Output authentication code: The final SHA-1 hash result is the HmacSHA1 authentication code.

HmacSHA1 provides a secure message authentication code algorithm that not only relies on the hashing properties of SHA-1, but also takes advantage of the mixing of keys. Therefore, even if an attacker is able to obtain the message and authentication code, it will be difficult to crack the original key, thereby protecting the integrity and security of the data.

When using HmacSHA1 in Java to interface-sign GET and POST request data and perform signature verification in an interceptor or filter, you can write the code as follows:

  1. Create an interceptor or filter class that implements the javax.servlet.Filter interface or the org.springframework.web.servlet.HandlerInterceptor interface.

  2. In the interceptor or filter, obtain relevant data of the GET or POST request, including URL, request parameters, etc.

  3. Get the appId, timestamp, nonce and signature in the request header.

  4. According to the rules defined by the interface, the appId, timestamp and nonce are spliced together in the specified order, and then the key is used to perform HmacSHA1 signature on the spliced string to generate a signature result.

  5. Compare the generated signature result with the signature in the request header. If they are consistent, the verification passes, otherwise the verification fails.

The following is a sample code that shows how to use HmacSHA1 in Java for interface signing and verification of GET and POST requests:

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

@WebFilter(urlPatterns = "/api/*")
public class SignatureFilter implements Filter {
    private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1";
    private static final String SECRET_KEY = "yourSecretKey";

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        //Initialization operation
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String method = httpRequest.getMethod();
        String url = httpRequest.getRequestURL().toString();

        // Get the parameters in the request header
        String appId = httpRequest.getHeader("appId");
        String timestamp = httpRequest.getHeader("timestamp");
        String nonce = httpRequest.getHeader("nonce");
        String signature = httpRequest.getHeader("signature");

        try {
            String signPayload = appId + timestamp + nonce;

            if (method.equalsIgnoreCase("POST")) {
                // Get the request body data of the POST request
                // It is assumed here that the request body is in JSON format. Because it is read in the form of a string, there is no problem of key disorder.
                String body = httpRequest.getReader().lines().reduce("", (accumulator, actual) -> accumulator + actual);

                signPayload + = body;
            } else if (method.equalsIgnoreCase("GET")) {
                // Get the parameters of the GET request
                String queryString = httpRequest.getQueryString();
                if (queryString != null) {
                    signPayload + = queryString;
                }
            }

            String generatedSignature = generateHmacSHA1Signature(signPayload);

            if (generatedSignature.equals(signature)) {
                // Signature verification passed, continue processing the request
                chain.doFilter(request, response);
            } else {
                //Failed to verify the signature and return an error response
                HttpServletResponse httpResponse = (HttpServletResponse) response;
                httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                httpResponse.getWriter().write("Invalid signature");
            }
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            e.printStackTrace();
            // Handle exceptions
        }
    }

    @Override
    public void destroy() {
        // Destroy operation
    }

    private String generateHmacSHA1Signature(String data) throws NoSuchAlgorithmException, InvalidKeyException {
        byte[] secretKeyBytes = SECRET_KEY.getBytes();
        SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyBytes, HMAC_SHA1_ALGORITHM);

        Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
        mac.init(secretKeySpec);

        byte[] dataBytes = data.getBytes();
        byte[] signatureBytes = mac.doFinal(dataBytes);

        return Base64.getEncoder().encodeToString(signatureBytes);
    }
}

SECRET_KEY in the above code example is the key used to generate HmacSHA1 signature. Please replace it with your own key. You can add additional validation logic and error handling logic based on your application needs.

Please note that in actual applications, it is also necessary to ensure the legitimacy of the appId, timestamp, and nonce in the request header, and to add appropriate timeliness checks and replay attack protection measures.