令牌認證是如何工作的(翻譯)

原文 : How token-based authentication works

令牌認證機制的工作原理

客戶端發(fā)送一個“硬憑證”(例如用戶名和密碼)到服務(wù)器踏兜,服務(wù)器返回一段數(shù)據(jù)作為令牌厢钧,之后客戶端與服務(wù)端之間通訊的時候則會以令牌代替硬憑證。這就是基于令牌的認證機制宇葱。

簡單來說筷黔,基于令牌的認證機制流程如下:

  1. 客戶端發(fā)送憑證(例如用戶名和密碼)到服務(wù)器往史。
  2. 服務(wù)器驗證憑證是否有效,并生成一個令牌佛舱。
  3. 服務(wù)器把令牌連同用戶信息和令牌有效期存儲起來。
  4. 服務(wù)器發(fā)送生成好的令牌到客戶端挨决。
  5. 在接下來的每次請求中请祖,客戶端都會發(fā)送令牌到服務(wù)器
  6. 服務(wù)器會從請求中取出令牌,并根據(jù)令牌作鑒權(quán)操作
    • 如果令牌有效脖祈,服務(wù)器接受請求肆捕。
    • 如果令牌無效,服務(wù)器拒絕請求盖高。
  7. 服務(wù)器可能會提供一個接口去刷新過期的令牌

你可以利用 JAX-RS 2.0 干些什么(Jersey, RESTEasy 和 Apache CXF)

下面的示例只使用了 JAX-RS 2.0 的API慎陵,沒有用到其他的框架眼虱。所以能夠在 JerseyRESTEasyApache CXF 等 JAX-RS 2.0 實現(xiàn)中正常工作席纽。

需要特別提醒的是捏悬,如果你要用基于令牌的認證機制,你將不依賴任何由 Servlet 容器提供的標準 Java EE Web 應(yīng)用安全機制润梯。

通過用戶名和密碼認證用戶并頒發(fā)令牌

創(chuàng)建一個用于驗證憑證(用戶名和密碼)并生成用戶令牌的方法:

@Path("/authentication")
public class AuthenticationEndpoint {

    @POST
    @Produces("application/json")
    @Consumes("application/x-www-form-urlencoded")
    public Response authenticateUser(@FormParam("username") String username, 
                                     @FormParam("password") String password) {

        try {

            // Authenticate the user using the credentials provided
            authenticate(username, password);

            // Issue a token for the user
            String token = issueToken(username);

            // Return the token on the response
            return Response.ok(token).build();

        } catch (Exception e) {
            return Response.status(Response.Status.UNAUTHORIZED).build();
        }      
    }

    private void authenticate(String username, String password) throws Exception {
        // Authenticate against a database, LDAP, file or whatever
        // Throw an Exception if the credentials are invalid
    }

    private String issueToken(String username) {
        // Issue a token (can be a random String persisted to a database or a JWT token)
        // The issued token must be associated to a user
        // Return the issued token
    }
}

如果在驗證憑證的時候有任何異常拋出过牙,會返回 401 UNAUTHORIZED 狀態(tài)碼。

如果成功驗證憑證纺铭,將返回 200 OK 狀態(tài)碼并返回處理好的令牌給客戶端寇钉。客戶端必須在每次請求的時候發(fā)送令牌舶赔。

你希望客戶端用如下格式發(fā)送憑證的話:

username=admin&password=123456

你可以用一個類來包裝一下用戶名和密碼扫倡,畢竟直接用表單可能比較麻煩:

public class Credentials implements Serializable {

    private String username;
    private String password;

    // Getters and setters omitted
}

或者使用 JSON :

@POST
@Produces("application/json")
@Consumes("application/json")
public Response authenticateUser(Credentials credentials) {

    String username = credentials.getUsername();
    String password = credentials.getPassword();

    // Authenticate the user, issue a token and return a response
}

然后客戶端就能用這種形式發(fā)送憑證了:

{
  "username": "admin",
  "password": "123456"
}

從請求中取出令牌并驗證

客戶端需要在發(fā)送的 HTTP 請求頭中的 Authorization 處寫入令牌

Authorization: Bearer <token-goes-here>

需要注意的是竟纳,標準 HTTP 頭里的這個名字是不對的镊辕,因為它存儲的是認證信息(authentication)而不是授權(quán)(authorization)。

JAX-RS 提供一個叫 @NameBinding 的元注解來給攔截器和過濾器創(chuàng)建命名綁定注解蚁袭。

@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }

@Secured 將會用來標記在實現(xiàn)了 ContainerRequestFilter 的類(過濾器)上以處理請求征懈。 ContainerRequestContext 可以幫你把令牌從 HTTP 請求中拿出來。

@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {

        // Get the HTTP Authorization header from the request
        String authorizationHeader = 
            requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);

        // Check if the HTTP Authorization header is present and formatted correctly 
        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            throw new NotAuthorizedException("Authorization header must be provided");
        }

        // Extract the token from the HTTP Authorization header
        String token = authorizationHeader.substring("Bearer".length()).trim();

        try {

            // Validate the token
            validateToken(token);

        } catch (Exception e) {
            requestContext.abortWith(
                Response.status(Response.Status.UNAUTHORIZED).build());
        }
    }

    private void validateToken(String token) throws Exception {
        // Check if it was issued by the server and if it's not expired
        // Throw an Exception if the token is invalid
    }
}

如果在驗證令牌的時候有任何異常拋出揩悄,會返回 401 UNAUTHORIZED 狀態(tài)碼卖哎。

如果驗證成功,則會調(diào)用被請求的方法删性。

給 RESTful 接口增加安全措施

把之前寫好的 @Secure 注解打在你的方法或者類上亏娜,就能把過濾器綁定上去了。被打上注解的類或者方法都會觸發(fā)過濾器蹬挺,也就是說這些接口只有在通過了鑒權(quán)之后才能被執(zhí)行维贺。

如果有些方法或者類不需要鑒權(quán),不打注解就行了巴帮。

@Path("/")
public class MyEndpoint {

    @GET
    @Path("{id}")
    @Produces("application/json")
    public Response myUnsecuredMethod(@PathParam("id") Long id) {
        // This method is not annotated with @Secured
        // The authentication filter won't be executed before invoking this method
        ...
    }

    @DELETE
    @Secured
    @Path("{id}")
    @Produces("application/json")
    public Response mySecuredMethod(@PathParam("id") Long id) {
        // This method is annotated with @Secured
        // The authentication filter will be executed before invoking this method
        // The HTTP request must be performed with a valid token
        ...
    }
}

在上面的例子里溯泣,過濾器只會在 mySecuredMethod(Long) 被調(diào)用的時候觸發(fā)(因為打了注解嘛)。

驗證當前用戶

你很有可能會需要知道是哪個用戶在請求你的 RESTful 接口榕茧,接下來的方法會比較有用:

重載 SecurityContext

通過使用 ContainerRequestFilter.filter(ContainerRequestContext) 這個方法垃沦,你可以給當前請求設(shè)置新的安全上下文(Secure Context)

重載 SecurityContext.getUserPrincipal() 用押,返回一個 Principal 實例肢簿。

Principal 的名字(name)就是令牌所對應(yīng)的用戶名(usrename)。當你驗證令牌的時候會需要它。

requestContext.setSecurityContext(new SecurityContext() {

    @Override
    public Principal getUserPrincipal() {

        return new Principal() {

            @Override
            public String getName() {
                return username;
            }
        };
    }

    @Override
    public boolean isUserInRole(String role) {
        return true;
    }

    @Override
    public boolean isSecure() {
        return false;
    }

    @Override
    public String getAuthenticationScheme() {
        return null;
    }
});

注入 SecurityContext 的代理到 REST 接口類里池充。

@Context
SecurityContext securityContext;

在方法里做也是可以的桩引。

@GET
@Secured
@Path("{id}")
@Produces("application/json")
public Response myMethod(@PathParam("id") Long id, 
                         @Context SecurityContext securityContext) {
    ...
}

獲取 Principal

Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();

使用 CDI (Context and Dependency Injection)

如果因為某些原因你不想重載 SecurityContext 的話,你可以使用 CDI 收夸,它能提供很多諸如事件和提供者(producers)坑匠。

創(chuàng)建一個 CDI 限定符用來處理認證事件以及把已認證的用戶注入到 bean 里。

@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }

AuthenticationFilter 里注入一個 Event

@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;

當認證用戶的時候咱圆,以用戶名作為參數(shù)去觸發(fā)事件(注意笛辟,令牌必須已經(jīng)關(guān)聯(lián)到用戶,并且能通過令牌查出用戶名)

userAuthenticatedEvent.fire(username);

一般來說在應(yīng)用里會有一個 User 類去代表用戶序苏。下面的代碼處理認證事件手幢,通過用戶名去查找一個用戶且賦給 authenticatedUser

@RequestScoped
public class AuthenticatedUserProducer {

    @Produces
    @RequestScoped
    @AuthenticatedUser
    private User authenticatedUser;

    public void handleAuthenticationEvent(@Observes @AuthenticatedUser String username) {
        this.authenticatedUser = findUser(username);
    }

    private User findUser(String username) {
        // Hit the the database or a service to find a user by its username and return it
        // Return the User instance
    }
}

authenticatedUser 保存了一個 User 的實例,便于注入到 bean 里面(例如 JAX-RS 服務(wù)忱详、CDI beans围来、servlet 以及 EJBs)

@Inject
@AuthenticatedUser
User authenticatedUser;

要注意 CDI @Produces 注解和 JAX-RS 的 @Produces 注解是不同的

支持基于角色的權(quán)限認證

除了認證,你還可以讓你的 RESTful API 支持基于角色的權(quán)限認證(RBAC)匈睁。

創(chuàng)建一個枚舉监透,并根據(jù)你的需求定義一些角色:

public enum Role {
    ROLE_1,
    ROLE_2,
    ROLE_3
}

針對 RBAC 改變一下 @Secured 注解:

@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured {
    Role[] value() default {};
}

給方法打上注解,這樣就能實現(xiàn) RBAC 了航唆。

注意 @Secured 注解可以在類以及方法上使用胀蛮。接下來的例子演示一下方法上的注解覆蓋掉類上的注解的情況:

@Path("/example")
@Secured({Role.ROLE_1})
public class MyEndpoint {

    @GET
    @Path("{id}")
    @Produces("application/json")
    public Response myMethod(@PathParam("id") Long id) {
        // This method is not annotated with @Secured
        // But it's declared within a class annotated with @Secured({Role.ROLE_1})
        // So it only can be executed by the users who have the ROLE_1 role
        ...
    }

    @DELETE
    @Path("{id}")    
    @Produces("application/json")
    @Secured({Role.ROLE_1, Role.ROLE_2})
    public Response myOtherMethod(@PathParam("id") Long id) {
        // This method is annotated with @Secured({Role.ROLE_1, Role.ROLE_2})
        // The method annotation overrides the class annotation
        // So it only can be executed by the users who have the ROLE_1 or ROLE_2 roles
        ...
    }
}

使用 AUTHORIZATION 優(yōu)先級創(chuàng)建一個過濾器,它會在先前定義的過濾器之后執(zhí)行糯钙。

ResourceInfo 可以用來獲取到匹配請求 URL 的 以及 方法 粪狼,并且把注解提取出來。

@Secured
@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {

    @Context
    private ResourceInfo resourceInfo;

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {

        // Get the resource class which matches with the requested URL
        // Extract the roles declared by it
        Class<?> resourceClass = resourceInfo.getResourceClass();
        List<Role> classRoles = extractRoles(resourceClass);

        // Get the resource method which matches with the requested URL
        // Extract the roles declared by it
        Method resourceMethod = resourceInfo.getResourceMethod();
        List<Role> methodRoles = extractRoles(resourceMethod);

        try {

            // Check if the user is allowed to execute the method
            // The method annotations override the class annotations
            if (methodRoles.isEmpty()) {
                checkPermissions(classRoles);
            } else {
                checkPermissions(methodRoles);
            }

        } catch (Exception e) {
            requestContext.abortWith(
                Response.status(Response.Status.FORBIDDEN).build());
        }
    }

    // Extract the roles from the annotated element
    private List<Role> extractRoles(AnnotatedElement annotatedElement) {
        if (annotatedElement == null) {
            return new ArrayList<Role>();
        } else {
            Secured secured = annotatedElement.getAnnotation(Secured.class);
            if (secured == null) {
                return new ArrayList<Role>();
            } else {
                Role[] allowedRoles = secured.value();
                return Arrays.asList(allowedRoles);
            }
        }
    }

    private void checkPermissions(List<Role> allowedRoles) throws Exception {
        // Check if the user contains one of the allowed roles
        // Throw an Exception if the user has not permission to execute the method
    }
}

如果用戶沒有權(quán)限去執(zhí)行這個方法任岸,請求會被跳過再榄,并返回 403 FORBIDDEN

重新看看上面的部分享潜,即可明白如何獲知是哪個用戶在發(fā)起請求困鸥。你可以從 SecurityContext 處獲取發(fā)起請求的用戶(指已經(jīng)被設(shè)置在 ContainerRequestContext 的用戶),或者通過 CDI 注入用戶信息剑按,這取決于你的情況疾就。

如果沒有傳遞角色給 @Secured 注解,則所有的令牌通過了檢查的用戶都能夠調(diào)用這個方法吕座,無論這個用戶擁有什么角色虐译。

如何生成令牌

令牌可以是不透明的,它不會顯示除值本身以外的任何細節(jié)(如隨機字符串)吴趴,也可以是自包含的(如JSON Web Token)。

隨機字符串

可以通過生成一個隨機字符串,并把它連同有效期锣枝、關(guān)聯(lián)的用戶儲存到數(shù)據(jù)庫厢拭。下面這個使用 Java 生成隨機字符串的例子就比較好:

Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);

Json Web Token (JWT)

JSON Web Token (JWT) 是 RFC 7519 定義的,用于在雙方之間安全地傳遞信息的標準方法撇叁。它不僅只是自包含的令牌供鸠,而且它還是一個載體,允許你儲存用戶標識陨闹、有效期以及其他信息(除了密碼)楞捂。 JWT 是一段用 Base64 編碼的 JSON。

這個載體能夠被客戶端讀取趋厉,且可以讓服務(wù)器方便地通過簽名校驗令牌的有效性寨闹。

如果你不需要跟蹤令牌,那就不需要存儲 JWT 令牌君账。當然繁堡,儲存 JWT 令牌可以讓你控制令牌的失效與重新頒發(fā)。如果既想跟蹤 JWT 令牌乡数,又不想存儲它們椭蹄,你可以存儲令牌標識( jti 信息)和一些元數(shù)據(jù)(令牌頒發(fā)給哪個用戶,有效期等等)净赴。

有用于頒發(fā)以及校驗 JWT 令牌的 Java 庫(例如 這個 以及 這個 )绳矩。如果需要找 JWT 相關(guān)的資源,可以訪問 http://jwt.io玖翅。

你的應(yīng)用可以提供用于重新頒發(fā)令牌的功能翼馆,建議在用戶重置密碼之后重新頒發(fā)令牌。

記得刪除舊的令牌烧栋,不要讓它們一直占用數(shù)據(jù)庫空間写妥。

一些建議

  • 不管你用的是哪一類型的認證方式,切記要使用 HTTPS 审姓,以防中間人攻擊珍特。
  • 關(guān)于信息安全的更多內(nèi)容,請查閱 這個 問題魔吐。
  • 這篇文章 里扎筒,你可以找到一些與基于令牌的認證機制相關(guān)的內(nèi)容。
  • Apache DeltaSpike 提供如 security module 之類的可用于保護 REST 應(yīng)用的輕量級的 CDI 擴展酬姆。
  • 對 OAuth 2.0 協(xié)議的 Java 實現(xiàn)感興趣嗜桌?你可以看看 Apache Oltu project
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末辞色,一起剝皮案震驚了整個濱河市骨宠,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖层亿,帶你破解...
    沈念sama閱讀 216,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件桦卒,死亡現(xiàn)場離奇詭異,居然都是意外死亡匿又,警方通過查閱死者的電腦和手機方灾,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來碌更,“玉大人裕偿,你說我怎么就攤上這事⊥吹ィ” “怎么了嘿棘?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長桦他。 經(jīng)常有香客問我蔫巩,道長,這世上最難降的妖魔是什么快压? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任圆仔,我火速辦了婚禮,結(jié)果婚禮上蔫劣,老公的妹妹穿的比我還像新娘坪郭。我一直安慰自己,他們只是感情好脉幢,可當我...
    茶點故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布歪沃。 她就那樣靜靜地躺著,像睡著了一般嫌松。 火紅的嫁衣襯著肌膚如雪沪曙。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天萎羔,我揣著相機與錄音液走,去河邊找鬼。 笑死贾陷,一個胖子當著我的面吹牛缘眶,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播髓废,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼慌洪!你這毒婦竟也來了顶燕?” 一聲冷哼從身側(cè)響起凑保,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎割岛,沒想到半個月后愉适,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體犯助,經(jīng)...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡癣漆,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了剂买。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片惠爽。...
    茶點故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖瞬哼,靈堂內(nèi)的尸體忽然破棺而出婚肆,到底是詐尸還是另有隱情,我是刑警寧澤坐慰,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布较性,位于F島的核電站,受9級特大地震影響结胀,放射性物質(zhì)發(fā)生泄漏赞咙。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一糟港、第九天 我趴在偏房一處隱蔽的房頂上張望攀操。 院中可真熱鬧,春花似錦秸抚、人聲如沸速和。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽颠放。三九已至,卻和暖如春吭敢,著一層夾襖步出監(jiān)牢的瞬間碰凶,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工省有, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留痒留,地道東北人。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓蠢沿,卻偏偏與公主長得像伸头,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子舷蟀,可洞房花燭夜當晚...
    茶點故事閱讀 44,611評論 2 353

推薦閱讀更多精彩內(nèi)容