Kerberos (Secure Network Authentication System,网络安全认证系统),是一种网络认证协议,其设计目标是通过密钥系统为 Client/Server 提供强大的认证服务。该认证过程的实现不依赖于主机操作系统的认证,无需基于的信任,不要求网络上所有主机的物理安全,并假定网络上传送的数据包可以被任意地读取、修改和插入数据。
SASL (Simple Authentication and Security Layer,简单授权和安全层),是一个互联网标准,它制定了一个授权协议,在 Client/Server 之间建立连接。SASL 定义了授权数据如何交换,但是并没有制定数据的内容。它是一个授权机制框架。
GSSAPI (Generic Security Services Application Program Interface,通用安全服务应用程序接口),也称 GSS-API,是程序访问安全服务的应用程序编程接口。GSSAPI 是 IETF 标准,用于解决当今使用的许多类似但不兼容的安全服务的问题。
GSSAPI 本身是一个独立的认证框架外,它同时也适配了 SASL,也就是说 GSSAPI 同时也是 SASL 规范下的一种认证机制,这就使得 SASL 可以通过 GSSAPI 间接支持 Kerberos,本文我们将使用 SASL/GSSAPI 指代两者。
Kerberos 的基本介绍和安装配置,可以参考 “Linux基础知识(16)- Kerberos (一) | Kerberos 安装配置”。
本文创建两个 Springboot 程序 Client 和 Server,演示通过 SASL/GSSAPI 访问 Kerberos 实现认证。
1. 系统环境
操作系统:Ubuntu 20.04
Java 版本:openjdk 11.0.18
本文 Kerberos 的客户端和服务端都安装在同一台主机上,主机名为 hadoop-master-vm,Springboot 程序也运行在 hadoop-master-vm 上。
2. 创建 Springboot 项目
Windows版本:Windows 10 Home (20H2)
IntelliJ IDEA:Community Edition for Windows 2020.1.4
Apache Maven:3.8.1
注:Spring 开发环境的搭建,可以参考 “ Spring基础知识(1)- Spring简介、Spring体系结构和开发环境配置 ”。
1) 运行 IDEA 创建一个空项目
点击菜单 New 创建 Project:
New Project -> Empty Project -> Next
Project Name: SpringbootExample24
Project location: 指定一个目录,比如 D:\Workshop\idea\SpringbootExample24
-> Finish
2) 添加 Server 模块 (Module)
点击菜单 File -> New 创建 Module:
New Module -> Maven -> Project Type: Maven -> Project SDK: 1.8 -> Check "Create from archtype" -> select "org.apache.maven.archtypes:maven-archtype-quickstart" -> Next
Name: Server
Location: D:\Workshop\idea\SpringbootExample24\Server
GroupId: com.example
ArtifactId: Server
Version: 1.0-SNAPSHOT
-> Next
Maven home directory: D:/Apps/Java/apache-maven-3.8.1 (本文的配置路径,下同)
User settings file: D:\Apps\Java\apache-maven-3.8.1\conf\settings.xml
Local repository: D:\Apps\Java\maven-repository
-> Finish
3) 添加 Client 模块 (Module)
点击菜单 File -> New 创建 Module:
New Module -> Maven -> Project Type: Maven -> Project SDK: 1.8 -> Check "Create from archtype" -> select "org.apache.maven.archtypes:maven-archtype-quickstart" -> Next
Name: Client
Location: D:\Workshop\idea\SpringbootExample24\Client
GroupId: com.example
ArtifactId: Client
Version: 1.0-SNAPSHOT
-> Next
Maven home directory: D:/Apps/Java/apache-maven-3.8.1
User settings file: D:\Apps\Java\apache-maven-3.8.1\conf\settings.xml
Local repository: D:\Apps\Java\maven-repository
-> Finish
3. Server 模块 (Module)
1) 修改 pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>Server</artifactId> <version>1.0-SNAPSHOT</version> <name>Server</name> <!-- FIXME change it to the project's website --> <url>http://www.example.com</url> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency> </dependencies> <build> <finalName>Server</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.ServerApp</mainClass> <layout>JAR</layout> </configuration> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) --> <plugins> ... </plugins> </pluginManagement> </build> </project>
在IDE中项目列表 -> Server -> 点击鼠标右键 -> Maven -> Reload Project
本文选择了 spring-boot-starter-parent 2.6.6 相关依赖包,spring-boot-starter 和 spring-boot-starter-test 的版本由 spring-boot-starter-parent 控制。
2) 配置文件
添加 src/main/resources/application.properties 文件,内容如下:
spring.main.banner-mode=off
添加 src/main/resources/krb5_testsrc.keytab 文件,这里使用 “Linux基础知识(17)- Kerberos (二) | krb5 API 的 C 程序示例” 里创建的 krb5_testsrc.keytab 文件。
3) 添加 src/main/java/com/example/ServerApp.java 文件
package com.example; import java.net.ServerSocket; import java.net.Socket; import java.util.Base64; import java.util.HashMap; import java.util.HashSet; import java.util.Set; import javax.security.auth.Subject; import javax.security.auth.kerberos.KerberosPrincipal; import javax.security.auth.login.AppConfigurationEntry; import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; import java.security.Principal; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; import java.io.*; import org.ietf.jgss.*; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ServerApp { private static String strRealm = "hadoop.com"; private static String strPrincipalServer = "testsrv/hadoop-master-vm"; private static String strKeytab = "krb5_testsrv.keytab"; private static String strKrb5MechOid = "1.2.840.113554.1.2.2"; private static String strKdcServer = "hadoop-master-vm"; private static String strServerHost = "hadoop-master-vm"; private static int iServerPort = 9988; public static void main(String[] args) { SpringApplication.run(ServerApp.class, args); System.setProperty("java.security.krb5.realm", strRealm); System.setProperty("java.security.krb5.kdc", strKdcServer); javax.security.auth.login.Configuration config = new javax.security.auth.login.Configuration() { @Override public AppConfigurationEntry[] getAppConfigurationEntry(String name) { HashMap<String, Object> options = new HashMap<String, Object>() { { put("useKeyTab", "true"); put("keyTab", strKeytab); put("principal", strPrincipalServer); put("doNotPrompt", "true"); put("storeKey", "true"); put("isInitiator", "true"); put("debug", "true"); } }; return new AppConfigurationEntry[]{ new AppConfigurationEntry("com.sun.security.auth.module.Krb5LoginModule", AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options) }; } }; try { System.out.println("Server Krb5Login ... "); final Set<Principal> principalSet = new HashSet<Principal>(1); principalSet.add(new KerberosPrincipal(strPrincipalServer)); Subject subject = new Subject(false, principalSet, new HashSet<Object>(), new HashSet<Object>()); LoginContext loginContext = new LoginContext(strPrincipalServer, subject, null, config); loginContext.login(); Subject serviceSubject = loginContext.getSubject(); System.out.println("Subject.doAs() ... "); GSSCredential gssCredential = Subject.doAs(serviceSubject, new PrivilegedExceptionAction<GSSCredential>() { final Oid krb5MechOid = new Oid(strKrb5MechOid); @Override public GSSCredential run() throws Exception { GSSManager manager = GSSManager.getInstance(); GSSName gssServerName = manager.createName(strPrincipalServer, GSSName.NT_USER_NAME); GSSCredential serverGssCreds = manager.createCredential(gssServerName, GSSCredential.INDEFINITE_LIFETIME, krb5MechOid, GSSCredential.ACCEPT_ONLY); return serverGssCreds; } }); if (gssCredential == null) { System.out.println("gssCredential == null"); return; } // ServerSocket serverSocket = new ServerSocket(iServerPort); OUTER: while (true) { System.out.println("serverSocket.accept() ..."); Socket connSocket = serverSocket.accept(); DataInputStream inStream = new DataInputStream(connSocket.getInputStream()); DataOutputStream outStream = new DataOutputStream(connSocket.getOutputStream()); System.out.println("client:" + connSocket.getInetAddress()); GSSManager manager = GSSManager.getInstance(); GSSName gssServerName = manager.createName(strPrincipalServer, GSSName.NT_USER_NAME); GSSContext gssContext = manager.createContext(gssServerName, null, gssCredential, GSSContext.DEFAULT_LIFETIME); // Do the context establish loop byte[] token = null; while (!gssContext.isEstablished()) { token = new byte[inStream.readInt()]; inStream.readFully(token); byte[] decodedToken = Base64.getDecoder().decode(token); System.out.println("gssContext.acceptSecContext(): decodedToken.length == " + decodedToken.length); token = gssContext.acceptSecContext(decodedToken, 0, decodedToken.length); // Send a token to the peer if one was generated by // acceptSecContext if (token != null) { System.out.println("outStream.writeInt(): token.length == " + token.length); outStream.writeInt(token.length); outStream.write(token); outStream.flush(); } } System.out.println("gssContext.isEstablished() == " + gssContext.isEstablished()); System.out.println("client: " + gssContext.getSrcName()); System.out.println("server: " + gssContext.getTargName()); if (gssContext.getMutualAuthState()) System.out.println("Mutual authentication is enable!"); // Normal message loop int done = 0; int count = 0; byte[] data = new byte[256]; do { try { count = inStream.readInt(); inStream.read(data); } catch (EOFException e) { System.out.println("EOFException(): client exit or network broken"); break; } if (count <= 0) { if (count < 0) { System.out.println("in.read(): error -> count == " + count); break; } done = 1; System.out.println("in.read(): done == " + done); } // Shutdown from client String str = new String(data); if ("shutdown".equals(str.substring(0, 8))) { System.out.println(str); connSocket.close(); gssContext.dispose(); break OUTER; } System.out.println("in.read(): from client -> " + str); Thread.sleep(2000); if (done <= 0) { outStream.writeInt(str.length()); outStream.write(data); outStream.flush(); System.out.println("outStream.write(): to client -> " + str); } } while (done <= 0); /* // Security message channel MessageProp prop = new MessageProp(0, false); token = new byte[inStream.readInt()]; System.out.println("Will read token of size " + token.length); inStream.readFully(token); byte[] bytes = gssContext.unwrap(token, 0, token.length, prop); String str = new String(bytes); System.out.println("Received data \"" + str + "\" of length " + str.length()); System.out.println("Confidentiality applied: " + prop.getPrivacy()); prop.setQOP(0); token = gssContext.getMIC(bytes, 0, bytes.length, prop); System.out.println("Will send MIC token of size " + token.length); outStream.writeInt(token.length); outStream.write(token); outStream.flush(); */ System.out.println("connSocket.close()"); connSocket.close(); gssContext.dispose(); } serverSocket.close(); } catch (LoginException e) { e.printStackTrace(); } catch (PrivilegedActionException e) { e.printStackTrace(); } catch (GSSException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } } }
4. Client 模块 (Module)
1) 修改 pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>Client</artifactId> <version>1.0-SNAPSHOT</version> <name>Client</name> <!-- FIXME change it to the project's website --> <url>http://www.example.com</url> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency> </dependencies> <build> <finalName>Client</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.ClientApp</mainClass> <layout>JAR</layout> </configuration> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) --> <plugins> ... </plugins> </pluginManagement> </build> </project>
在IDE中项目列表 -> Server -> 点击鼠标右键 -> Maven -> Reload Project
本文选择了 spring-boot-starter-parent 2.6.6 相关依赖包,spring-boot-starter 和 spring-boot-starter-test 的版本由 spring-boot-starter-parent 控制。
2) 配置文件
添加 src/main/resources/application.properties 文件,内容如下:
spring.main.banner-mode=off
添加 src/main/resources/krb5_testcli.keytab 文件,这里使用 “Linux基础知识(17)- Kerberos (二) | krb5 API 的 C 程序示例” 里创建的 krb5_testcli.keytab 文件。
3) 添加 src/main/java/com/example/ClientApp.java 文件
package com.example; import java.net.Socket; import java.util.Scanner; import java.util.HashMap; import java.util.Set; import java.util.HashSet; import java.util.Base64; import javax.security.auth.Subject; import javax.security.auth.kerberos.KerberosPrincipal; import javax.security.auth.login.AppConfigurationEntry; import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; import java.security.Principal; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; import java.io.*; import org.ietf.jgss.*; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ClientApp { private static String strRealm = "hadoop.com"; private static String strPrincipalClient = "testcli"; private static String strPrincipalServer = "testsrv/hadoop-master-vm"; private static String strKeytab = "krb5_testcli.keytab"; private static String strSpnegoOid = "1.3.6.1.5.5.2"; private static String strKrb5MechOid = "1.2.840.113554.1.2.2"; private static String strKdcServer = "hadoop-master-vm"; private static String strServerHost = "hadoop-master-vm"; private static int iServerPort = 9988; public static void main(String[] args) { SpringApplication.run(ClientApp.class, args); // Config System.setProperty("java.security.krb5.realm", strRealm); System.setProperty("java.security.krb5.kdc", strKdcServer); javax.security.auth.login.Configuration config = new javax.security.auth.login.Configuration() { @Override public AppConfigurationEntry[] getAppConfigurationEntry(String name) { HashMap<String, Object> options = new HashMap<String, Object>() { { put("useKeyTab", "true"); put("keyTab", strKeytab); put("principal", strPrincipalClient); put("doNotPrompt", "true"); put("storeKey", "true"); put("isInitiator", "true"); put("debug", "true"); } }; return new AppConfigurationEntry[]{ new AppConfigurationEntry("com.sun.security.auth.module.Krb5LoginModule", AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options) }; } }; try { System.out.println("Client Krb5Login ... "); final Set<Principal> principalSet = new HashSet<Principal>(1); principalSet.add(new KerberosPrincipal(strPrincipalClient)); Subject subject = new Subject(false, principalSet, new HashSet<Object>(), new HashSet<Object>()); LoginContext loginContext = new LoginContext(strPrincipalClient, subject, null, config); loginContext.login(); Subject serviceSubject = loginContext.getSubject(); System.out.println("Subject.doAs() ... "); GSSContext gssContext = Subject.doAs(serviceSubject, new PrivilegedExceptionAction<GSSContext>() { //final Oid spnegoOid = new Oid(strSpnegoOid); final Oid krb5MechOid = new Oid(strKrb5MechOid); @Override public GSSContext run() throws Exception { GSSManager manager = GSSManager.getInstance(); // GSSName gssClientName = manager.createName(strPrincipalClient, GSSName.NT_USER_NAME); GSSCredential clientGssCreds = manager.createCredential(gssClientName, GSSCredential.INDEFINITE_LIFETIME, krb5MechOid, GSSCredential.INITIATE_ONLY); // GSS ticket GSSName gssServerName = manager.createName(strPrincipalServer, GSSName.NT_USER_NAME); GSSContext context = manager.createContext(gssServerName, null, clientGssCreds, GSSContext.DEFAULT_LIFETIME); // GSS token //GSSContext context = manager.createContext(gssClientName.canonicalize(spnegoOid), // spnegoOid, // clientGssCreds, // GSSContext.DEFAULT_LIFETIME); return context; } }); if (gssContext == null) { System.out.println("gssContext == null"); return; } gssContext.requestCredDeleg(true); gssContext.requestMutualAuth(true); // Mutual authentication gssContext.requestConf(true); // Will use confidentiality later gssContext.requestInteg(true); // Will use integrity later // Connect to server Socket clientSocket = new Socket(strServerHost, iServerPort); DataInputStream inStream = new DataInputStream(clientSocket.getInputStream()); DataOutputStream outStream = new DataOutputStream(clientSocket.getOutputStream()); System.out.println("Connected to server: " + clientSocket.getInetAddress()); // Do the context loop byte[] token = new byte[0]; while (!gssContext.isEstablished()) { token = gssContext.initSecContext(token, 0, token.length); // Send a token to the server if one was generated by // initSecContext if (token != null) { byte[] encodedToken = Base64.getEncoder().encode(token); System.out.println("outStream.writeInt(): encodedToken.length == " + encodedToken.length); outStream.writeInt(encodedToken.length); outStream.write(encodedToken); outStream.flush(); } // If the client is done with context establishment // then there will be no more tokens to read in this loop if (!gssContext.isEstablished()) { token = new byte[inStream.readInt()]; System.out.println("inStream.writeInt(): token.length == " + token.length); inStream.readFully(token); } } System.out.println("gssContext.isEstablished() == " + gssContext.isEstablished()); System.out.println("client: " + gssContext.getSrcName()); System.out.println("server: " + gssContext.getTargName()); if (gssContext.getMutualAuthState()) System.out.println("Mutual authentication is enable!"); /* // Security message channel byte[] messageBytes = "Hello There!\0".getBytes(); MessageProp prop = new MessageProp(0, true); token = gssContext.wrap(messageBytes, 0, messageBytes.length, prop); System.out.println("Will send wrap token of size " + token.length); outStream.writeInt(token.length); outStream.write(token); outStream.flush(); token = new byte[inStream.readInt()]; System.out.println("Will read token of size " + token.length); inStream.readFully(token); gssContext.verifyMIC(token, 0, token.length, messageBytes, 0, messageBytes.length, prop); System.out.println("Verified received MIC for message."); */ // Normal message loop Scanner sc = new Scanner(System.in); System.out.print("Input> "); String str = sc.next(); byte[] data = new byte[256]; int count = 0; while (!str.equals("exit") && !str.equals("quit")) { outStream.writeInt(str.length()); outStream.write(str.getBytes()); if (str.equals("shutdown")) break; // InputStream in = clientSocket.getInputStream(); count = inStream.readInt(); inStream.read(data); if (count > 0) { System.out.println("in.read(): from server -> " + new String(data)); } else { System.out.println("in.read(): count == " + count); break; } System.out.print("Input> "); str = sc.next(); } System.out.println("Exiting ..."); sc.close(); clientSocket.close(); gssContext.dispose(); } catch (LoginException e) { e.printStackTrace(); } catch (PrivilegedActionException e) { e.printStackTrace(); } catch (GSSException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } } }
5. 运行
1) 打包 Jar
菜单 View -> Tool Windows -> Maven -> Client -> Lifecycle -> Clean & Package
Client 模块 (Module) 的 Jar 包生成在 Client 模块下的目录 target/ 里
Client.jar
Client.jar.original
注:Client.jar 包含依赖包,可以直接运行。 Client.jar.original 里不包含依赖的包(要手动配置依赖环境),运行前要把文件名上的 “.original” 去掉。
菜单 View -> Tool Windows -> Maven -> Server -> Lifecycle -> Clean & Package
Server 模块 (Module) 的 Jar 包生成在 Server 模块下的目录 target/ 里
Server.jar
Server.jar.original
2) 运行 Jar
把 Server.jar 和 Client.jar 复制到主机 hadoop-master-vm 上,在两个控制台分别运行这两个 Jar 包,运行命令如下。
控制台1:
$ java -jar Server.jar
... Server Krb5Login ... Debug is true storeKey true useTicketCache false useKeyTab true doNotPrompt true ticketCache is null isInitiator true KeyTab is krb5_testsrv.keytab refreshKrb5Config is false principal is testsrv/hadoop-master-vm tryFirstPass is false useFirstPass is false storePass is false clearPass is false principal is testsrv/hadoop-master-vm@hadoop.com Will use keytab Commit Succeeded Subject.doAs() ... serverSocket.accept() ... client: /192.168.1.5 gssContext.acceptSecContext(): token.length == 1600 outStream.writeInt(): token.length == 108 gssContext.isEstablished() == true client: testcli@hadoop.com server: testsrv/hadoop-master-vm Mutual authentication is enable!
控制台2:
$ java -jar Client.jar
Client Krb5Login ... Debug is true storeKey true useTicketCache false useKeyTab true doNotPrompt true ticketCache is null isInitiator true KeyTab is krb5_testcli.keytab refreshKrb5Config is false principal is testcli tryFirstPass is false useFirstPass is false storePass is false clearPass is false principal is testcli@hadoop.com Will use keytab Commit Succeeded Subject.doAs() ... Connected to server: hadoop-master-vm/192.168.1.5 outStream.writeInt(): encodedToken.length == 1600 inStream.writeInt(): token.length == 108 gssContext.isEstablished() == true client: testcli server: testsrv/hadoop-master-vm Mutual authentication is enable! Input> test
注: 输入文本 “test”,按回车键,Server 收到 “test” 后会发回 Client。输入 “exit” 或 “quit”,可以退出 Client 程序,Server 程序继续处于 accept 状态。输入 “shutdown”,Server 和 Client 都退出。
参考来源:https://docs.oracle.com/javase/8/docs/technotes/guides/security/jgss/tutorials/BasicClientServer.html