Thread: OAuth2 Login

Results 1 to 4 of 4
  1. #1 OAuth2 Login 
    Registered Member
    Join Date
    Jul 2022
    Posts
    1
    Thanks given
    0
    Thanks received
    2
    Rep Power
    0
    It has been brought to my attention that this will need significant work to integrate with OSRS deob clients. Please consider this working for 317 only.

    Hey all, been playing around with this idea for a little bit and thought I'd share the code. This feature was originally built for RSPS.app. Any comments and feedback are appreciated, but I will not be answering questions on how to implement this for your server.

    https://media.discordapp.net/attachm...922/cached.mov

    Because server bases may differ, this tutorial is educational in nature and the code will most likely not work if copy-pasted. The goal is to teach basic OAuth concepts and how to identify the proper places in your server to make modifications, not necessarily spoonfeed pre-written code. For completeness' sake, these modifications were made on an Elvarg-based server and client.

    Prerequisites
    • Basic knowledge of client/server architecture
    • Basic knowledge of the login protocol, how to modify packets
    • Discord application with the `identify` scope (see this tutorial if you don't have one)
    • Must have http://localhost:8080 as a redirect url


    Introduction to OAuth

    If you're not familiar with the concept of OAuth, I would strongly encourage you to read this primer and the related Authorization Code Grant tutorial. Although you don't need the knowledge to implement this feature by yourself, things will make a lot more sense and you will be able to understand what changes you are making if you do. Here are some important points:

    The major parts of the implementation are the following:
    1. The client performs the Authorization Code flow
    2. The server performs the Access Token exchange
    3. Finally, functionality will be added to store tokens so that the user doesn't have to log in every time


    Client Implementation

    The first section of this tutorial is the client-side implementation. We can further break down this part into three tasks:
    - Allow the user to initiate the OAuth flow
    - Pipe the authorization code back into the client for later use
    - Modify the login procedure to specify that we should use the authorization code

    Initiating the OAuth Flow
    Really, this is a fancy way of saying we need to pop open a browser for the user to log in with. In the discord developer panel, if you go to OAuth2 > URL Generator, this will give you the URL that you need to open in a browser. There should already be functionality in the client, in Elvarg the method is named `MiscUtils->launchURL`.

    Piping the authz code back into the client
    This is where things get fun. Usually, the OAuth2 flow is used in a website where you have a dedicated page that waits for a successful redirect with the code. How do we do this with a Runescape client since the browser can't necessarily communicate with the client java process?
    I solved this by hosting a lightweight web server on the player's computer, and set up a dummy webpage to intercept the authz code. For the web server, I used the `nanohttpd` library to do this, although you could certainly use something else (or perhaps write your own handling using sockets, would not recommend)

    Reference code for web server:
    Code:
    package app.rsps;
    
    /**
     * Basic singleton web server that listens on localhost:8080.
     *
     * @author shogun <[email protected]>
     */
    
    import com.runescape.Configuration.DiscordConfiguration;
    import fi.iki.elonen.NanoHTTPD;
    
    import java.io.IOException;
    import java.util.List;
    import java.util.Map;
    import java.util.function.Function;
    
    public class DiscordOAuth {
    
        private static DiscordOAuth instance;
    
        private DiscordOAuthListener httpd;
        private Thread runner;
        private Function<String, Void> callback;
    
        public static DiscordOAuth getInstance() {
            return instance;
        }
    
        public static String getOAuthUrl() {
            return "https://discord.com/api/oauth2/authorize?client_id=" + DiscordConfiguration.CLIENT_ID + "&redirect_uri=http%3A%2F%2Flocalhost%3A8080&response_type=code&scope=identify";
        }
    
        /**
         * Lightweight wrapper around the web framework. Intended to isolate the actual HTTP
         * processing of receiving the Discord callback
         */
        class DiscordOAuthListener extends NanoHTTPD {
            private DiscordOAuth parent;
            public DiscordOAuthListener(DiscordOAuth parent) {
                super(8080);
                this.parent = parent;
            }
    
            @Override
            public Response serve(IHTTPSession session) {
                if (parent.callback == null) return null;
    
                String msg = "<html><head><meta http-equiv=\"refresh\" content=\"0; URL=" + DiscordConfiguration.REDIRECT_URL + "\" /></head></html>";
    
                Map<String, List<String>> params = session.getParameters();
                if (params.containsKey("code")) {
                    String code = params.get("code").get(0);
                    this.parent.callback(code);
                }
    
                return NanoHTTPD.newFixedLengthResponse(msg);
            }
        }
    
        public DiscordOAuth() {
            httpd = new DiscordOAuthListener(this);
    
            runner = new Thread(() -> {
                try {
                    httpd.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
                } catch (IOException ioe) {
                    System.exit(-1);
                }
            });
            runner.start();
        }
    
        public void setCallback(Function<String, Void> callback) {
            this.callback = callback;
        }
    
        public void callback(String token) {
            this.callback.apply(token);
            this.callback = null;
        }
    
        static {
            instance = new DiscordOAuth();
        }
    }
    Modifying the login procedure
    This is the part that might be the trickiest if you haven't done much client-side programming. I'll attempt to list the various modifications I did in roughly chronological order; however, this will probably differ between client versions.

    Repurpose the "New User" button as "Login with Discord": when clicked, activate the webserver callback in the DiscordOAuth object:

    Next, we need to set up the callback for when the user is redirected from Discord and hits our locally hosted webpage to intercept the code. We want to store it in a client variable so that the next render loop can read it and perform any login logic.

    In Client.java, under `processLoginScreenInput()`:
    Code:
     
    if (super.clickMode3 == 1) {
       ...
        if (mouseInRegion(229, 375, 271, 312)) {
          DiscordOAuth.getInstance().setCallback((code) -> {
             discordCode = code;
             firstLoginMessage = "Authenticating with server...";
             return null;
          });
    
         MiscUtils.launchURL(DiscordOAuth.getOAuthUrl());
         firstLoginMessage = "Waiting for OAuth...";
         secondLoginMessage = "";
         loginScreenState = 1;
      }
    }
    Switch login screen to state 1, and when callback is received, attempt to log in on the next process input on render.
    On Client.java, processLoginScreenInput():
    Code:
    if (loginScreenState == 1) {
      if (discordCode != null) {
        login("authz_code", discordCode, false, true);
    
        discordCode = null;
        return;
      }
    }
    Notice that I had to add a discordCode instance variable.

    Finally, in the actual `login` method, we must denote that the username and variable need to be processed as discord login input. I did this by switching the security byte (sent before the 4 ISAAC ints) to 11 rather than 10.

    That's it for the client side! Other two tutorials are coming soon. Here's the implementation PR that you should definitely read as the client-side modifications do not make much sense on their own.




    Part 2: Server-side Integration (WIP)

    The next part of the integration is setting up how the server communicates with Discord to fetch the user credentials. Similarly, there are a few big goals that we need to accomplish to do this:
    - Exchanging the client authorization code obtained from login for an access token
    - Communicating with Discord to generate the access token
    - Use discord's identify information to generate server-side info to log in with

    The most crucial part here is that we treat the access token obtained from Discord as a valid username and password pair.

    This part will be pretty free-form, as I cut some corners while implementing this and there's a lot of room for improvement (which I'll talk about at the end). Also, depending on your server base, this will most likely look totally different from one source to another. The most important things to take away from this post are the token exchange flow, and perhaps the Player model modifications.

    Decoding the login packet
    The area for modification here is inside the login packet handler (at the end of the previous post, there was a line on changing the "security bit" to 11 if we're attempting to log in via Discord. So wherever that would be handled). Since we assume the client-sided change has already been made, we need to now switch login handling. In Elvarg, the end result of the login packet handling is that a LoginDetailsMessage object is passed to a decoder down the pipeline. If we see that the security byte is set to 11 and that we've received an oauth code, then we know we need to do more work to fetch the username and password (assuming you used the username / pw fields to pass the code from the client).

    That looks something like this in the Elvarg code:
    Code:
               String rawUsername = ByteBufUtils.readString(rsaBuffer);
                String password = ByteBufUtils.readString(rsaBuffer);
    
                if (securityId == 10) {
                    String username = Misc.formatText(rawUsername.toLowerCase());
    
                    if (username.length() < 3 || username.length() > 30 || password.length() < 3 || password.length() > 30) {
                        sendLoginResponse(ctx, LoginResponses.INVALID_CREDENTIALS_COMBINATION);
                        return;
                    }
    
                    out.add(new LoginDetailsMessage(ctx, username, password, host,
                            new IsaacRandom(seed), decodingRandom));
                } else if (securityId == 11) {
                    if (rawUsername.equals("authz_code")) {
                        var msg = new LoginDetailsMessage(ctx, rawUsername, password, host,
                                new IsaacRandom(seed), decodingRandom);
                        msg.setDiscord(true);
                        out.add(msg);
                    } else {
                        sendLoginResponse(ctx, LoginResponses.INVALID_CREDENTIALS_COMBINATION);
                    }
                }
    Performing the Token Exchange
    Great! So now we have a login message with the authz code that is also flagged as a Discord login. As an aside, we'll implement a tool to make requests to the Discord API. You should be able to copy-paste this into your codebase, all you need is to add `okhttp` as a dependency (that's what we use to make API requests).

    Code:
    package com.elvarg.util;
    
    import com.google.gson.Gson;
    import okhttp3.*;
    
    import java.io.IOException;
    import java.util.UUID;
    
    /**
     * Helper class for performing various Discord API functions.
     *
     * @author shogun <[email protected]>
     */
    public class DiscordUtil {
    
        public static class DiscordConstants {
            private static final String CLIENT_ID = "1010001099815669811";
            private static final String CLIENT_SECRET = "";
            private static final String TOKEN_ENDPOINT = "https://discord.com/api/oauth2/token";
            private static final String OAUTH_IDENTITY_ENDPOINT = "https://discord.com/api/oauth2/@me";
            private static final String IDENTITY_ENDPOINT = "https://discord.com/api/v10/users/@me";
    
            public static final String USERNAME_AUTHZ_CODE = "authz_code";
            public static final String USERNAME_CACHED_TOKEN = "cached_token";
        }
    
        static OkHttpClient httpClient;
    
        public static class DiscordInfo {
            public String username, password;
            public String token;
        }
    
        private static class AccessTokenResponse {
            String access_token;
        }
    
        private static class UserResponse {
            String id;
            String username;
            String discriminator;
        }
    
        public static AccessTokenResponse getAccessToken(String code) throws IOException {
            RequestBody formBody = new FormBody.Builder()
                    .add("client_id", DiscordConstants.CLIENT_ID)
                    .add("client_secret", DiscordConstants.CLIENT_SECRET)
                    .add("grant_type", "authorization_code")
                    .add("code", code)
                    .add("redirect_uri", "http://localhost:8080")
                    .build();
    
            Request req = new Request.Builder()
                    .url(DiscordConstants.TOKEN_ENDPOINT)
                    .addHeader("Content-Type", "application/x-www-form-urlencoded")
                    .post(formBody)
                    .build();
    
            Response response = httpClient.newCall(req).execute();
            AccessTokenResponse resp = new Gson().fromJson(response.body().string(), AccessTokenResponse.class);
    
            return resp;
        }
    
        public static UserResponse getUserInfo(String token) throws IOException {
            Request req = new Request.Builder()
                    .url(DiscordConstants.IDENTITY_ENDPOINT)
                    .addHeader("Authorization", "Bearer " + token)
                    .get()
                    .build();
    
            Response response = httpClient.newCall(req).execute();
            UserResponse resp = new Gson().fromJson(response.body().string(), UserResponse.class);
    
            return resp;
        }
    
        public static boolean isTokenValid(String token) throws IOException {
            Request req = new Request.Builder()
                    .url(DiscordConstants.OAUTH_IDENTITY_ENDPOINT)
                    .addHeader("Authorization", "Bearer " + token)
                    .get()
                    .build();
            Response response = httpClient.newCall(req).execute();
            return response.isSuccessful();
        }
    
        public static DiscordInfo getDiscordInfoWithCode(String code) throws IOException {
            AccessTokenResponse token = getAccessToken(code);
            UserResponse userInfo = getUserInfo(token.access_token);
    
            DiscordInfo ret = new DiscordInfo();
            ret.username = userInfo.username + "_" + userInfo.discriminator;
            ret.password = UUID.randomUUID().toString();
            ret.token = token.access_token;
    
            return ret;
        }
    
        static {
            httpClient = new OkHttpClient();
        }
    }
    Login Response Finalization
    Finally, we can tie the two pieces together. In Elvarg, eventually the login flow hits LoginResponses.getPlayerResult, which takes in the LoginDetailsMessage that we generated from the login packet. If we see that `isDiscord` is set on this, then we add some custom handling. Here's what it looks like from my implementation:
    Code:
    private static int getDiscordResult(Player player, LoginDetailsMessage msg) {
            try {
                if (msg.getUsername().equals(DiscordUtil.DiscordConst  ants.USERNAME_AUTHZ_CODE)) {
                    var discordInfo = DiscordUtil.getDiscordInfoWithCode(msg.getPassword  ());
                    player.setUsername(discordInfo.username);
    
                    var playerSave = PLAYER_PERSISTENCE.load(player.getUsername());
                    if (playerSave == null) {
                        player.setDiscordLogin(true);
                        player.setCachedDiscordAccessToken(discordInfo.tok  en);
                        player.setPasswordHashWithSalt(discordInfo.passwor  d);
                        return LoginResponses.NEW_ACCOUNT;
                    }
    
                    playerSave.applyToPlayer(player);
                    return LoginResponses.LOGIN_SUCCESSFUL;
                }
            } catch (IOException ex) {
            }
    
            return LoginResponses.LOGIN_INVALID_CREDENTIALS;
        }
    As before, here is a reference PR with a little more context around where this code was modified.




    Part 3: Caching Access Tokens (WIP)

    We already have a working client <-> server discord communication, so this is more of an add-on (but probably pretty important for making sure people actually use the feature, as we don't want the player to have to do the whole "click through discord" flow every time they try to log in).

    Player Object Modifications
    First of all, we introduce two new fields on to the `Player` and `PlayerSave` objects: `isDiscord` and `cachedDiscordToken`. This will allow us to attach some login information on to the Player object.

    Client-sided Modifications
    Next, to use cached tokens, we need to add some support in the client. I added two fields to Client.java: String discordToken and boolean canUseCachedToken (more on this later).
    When the client modifies or reads preferences, we add some code to add or read the discord token at the end of the preferences file.

    Danger! Storing the access token in plaintext (or encoded, w/e) opens you up to this attack. It's a bit more complicated than just exfiltrating a username/password pair, but there is always a risk if you're storing unencrypted login credentials.

    Under the chat message handler (search for :tradereq:), add some handling to update the discord token if the message matches some pattern. We will send a message later from the server with an updated Discord token (also be wary of attacks here, as checking an insecure pattern could end up in a malicious player chat overwriting cached tokens).
    Example:
    Code:
    else if (message.endsWith(":chalreq:")) {
                        String name = message.substring(0, message.indexOf(":"));
                        long encodedName = StringUtils.encodeBase37(name);
                        boolean ignored = false;
                        for (int index = 0; index < ignoreCount; index++) {
                            if (ignoreListAsLongs[index] != encodedName)
                                continue;
                            ignored = true;
                        }
                        if (!ignored && onTutorialIsland == 0) {
                            String msg = message.substring(message.indexOf(":") + 1,
                                    message.length() - 9);
                            sendMessage(msg, 8, name);
                        }
                    } else if (message.startsWith(":discordtoken:")) {
                        discordToken = message.replace(":discordtoken:", "").trim();
                        savePlayerData();
                    }
    Client modifications: Login
    Notice that we added an extra boolean, `canUseCachedToken` in Client above. I did this to prevent retriggering logins -- once a login message has been sent, we should not re-attempt login. So here's what our loginScreenState == 1 handler looks like now. Notice that we change the pre-defined usernames based on which discord flow we're using (authz code vs. cached token):
    Code:
    else if (loginScreenState == 1) {
                if (discordCode != null) {
                    login("authz_code", discordCode, false, true);
                    discordCode = null;
                    return;
                } else if (discordToken != "" && canUseCachedToken) {
                    login("cached_token", discordToken, false, true);
                    canUseCachedToken = false;
    
                    return;
                }
            }
    Server-side Modifications
    Finally, to bring this all together, we will need to handle discord logins with the cached_token model on the server-side. Luckily, this is pretty easy since we already have the code for getting user info using an authz code.

    Here is the LoginResponses handler:
    Code:
    private static int getDiscordResult(Player player, LoginDetailsMessage msg) {
            try {
                DiscordUtil.DiscordInfo discordInfo;
                if (msg.getUsername().equals(DiscordUtil.DiscordConst  ants.USERNAME_AUTHZ_CODE)) {
                    discordInfo = DiscordUtil.getDiscordInfoWithCode(msg.getPassword  ());
                } else if (msg.getUsername().equals(DiscordUtil.DiscordConst  ants.USERNAME_CACHED_TOKEN)) {
                    if (!DiscordUtil.isTokenValid(msg.getPassword())) return LoginResponses.LOGIN_INVALID_CREDENTIALS;
                    discordInfo = DiscordUtil.getDiscordInfoWithToken(msg.getPasswor  d());
                } else {
                    return LoginResponses.LOGIN_INVALID_CREDENTIALS;
                }
    
                player.setUsername(discordInfo.username);
    
                var playerSave = PLAYER_PERSISTENCE.load(player.getUsername());
                if (playerSave == null) {
                    player.setDiscordLogin(true);
                    player.setCachedDiscordAccessToken(discordInfo.tok  en);
                    player.setPasswordHashWithSalt(discordInfo.passwor  d);
                    return LoginResponses.NEW_ACCOUNT;
                }
    
                playerSave.applyToPlayer(player);
                return LoginResponses.LOGIN_SUCCESSFUL;
    
            } catch (IOException ex) {
            }
    That should be it! Thanks for reading, and as always, here is the reference PR.




    Shameless plugs:
    - If these instructions are unclear or differ too much from what's in your codebase, you can hire me to help you with integration.
    - If you have a cool project going on and need devs, let me know! You can contact me via PM on here or on Discord. Shogun#0939
    Reply With Quote  
     

  2. Thankful users:


  3. #2  
    BoomScape #1
    BoomScape's Avatar
    Join Date
    May 2013
    Posts
    2,422
    Thanks given
    289
    Thanks received
    234
    Rep Power
    48
    Nice release and breakdown!
    Attached image
    Reply With Quote  
     

  4. #3  
    V.C.C.

    Abnant's Avatar
    Join Date
    Nov 2010
    Posts
    2,291
    Thanks given
    393
    Thanks received
    796
    Rep Power
    1777
    Nice job
    Reply With Quote  
     

  5. #4  
    Registered Member
    Join Date
    Jan 2022
    Posts
    49
    Thanks given
    1
    Thanks received
    10
    Rep Power
    11
    You done a stellar job on this dude. Can't thank you enough and its great having reliable devs like you on board
    Looking forward to doing more innovative stoof with you
    Reply With Quote  
     


Thread Information
Users Browsing this Thread

There are currently 1 users browsing this thread. (0 members and 1 guests)


User Tag List

Similar Threads

  1. Char screen on first login only.
    By ~Legend Rene in forum Tutorials
    Replies: 26
    Last Post: 02-25-2008, 06:17 PM
  2. Replies: 7
    Last Post: 02-07-2008, 01:10 PM
  3. Global Login and Logout Messages
    By Runite in forum Tutorials
    Replies: 11
    Last Post: 09-19-2007, 04:12 PM
  4. [TuT]Stopping multi logins through user ips
    By Wolf in forum Tutorials
    Replies: 21
    Last Post: 09-06-2007, 03:20 AM
  5. Getting player's UID at login
    By Pandora in forum Tutorials
    Replies: 10
    Last Post: 08-26-2007, 11:17 AM
Posting Permissions
  • You may not post new threads
  • You may not post replies
  • You may not post attachments
  • You may not edit your posts
  •