mirror of
https://github.com/MirrorNetworking/Mirror.git
synced 2024-11-18 02:50:32 +00:00
feat: Authentication Framework (#1057)
* Component-based Authentication
* Capitalized IsAuthenticated
* Added isAuthenticated to NetworkConnection
* Removed activeAuthenticator as unnecessary
* Removed unnecessary using
* Added more comments
* Documentation
* Added cs to code blocks in doc
* fixed typo in doc
* Doc improvements
* Fixed another typo in doc
* Removed HideInInspector
* Updated doc and image
* Fixed comment
* Added inspector header and tooltips
* Fixed typo
* Add AuthenticationData object
* Add a bullet point in the doc about AuthenticationData
* Updated screenshot image
* Added HelpURL attribute
* Added Initializers for both Server and Client
* Fixed doc grammar and phrasing
* Forgot to add the ClientInitialize in StartHost
* Updated doc with info about the initializers
* Changed initializers from bool to void.
* Eliminated the abstract model and renamed to NetworkAuthenticator and made all methods virtual
* Fixed comment
* Fixed typo
* Doc cleanup
* Doc Cleanup
* authenticator RemoveAllListeners in StopServer and StopClient
* Update Assets/Mirror/Runtime/NetworkManager.cs
Co-Authored-By: vis2k <info@noobtuts.com>
* Changes requested by Vis
* reverted conflicting change
* Revert "reverted conflicting change"
This reverts commit f65870e073
.
* UnityEditor.Undo.RecordObject
* made the name camelCase
* Added internal methods and On prefix to methods
* Reverted this change so it can be done in a separate PR
* Moved authenticator calls to after runInBackground
* Add built-in timeout feature
* Changed UnityEditor.Undo.RecordObject to use gameobject
* Convert to Abstract, add Basic Authenticator, update docs.
* Removed timeout, against my better judgement.
* Removed the rest of timeout, still against my better judgement
* Fixed event listener mappings
* Renamed and consolidated methods
* updated doc and image
* made OnClientAuthenticate and OnServerAuthenticate abstract
* Updated Debug log msgs
* changed to authenticator != null
* Renamed to NetworkAuthenticator
This commit is contained in:
parent
e67035e971
commit
56bcb02c15
8
Assets/Mirror/Authenticators.meta
Normal file
8
Assets/Mirror/Authenticators.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b2f9d254154cd942ba40b06b869b8f3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
116
Assets/Mirror/Authenticators/BasicAuthenticator.cs
Normal file
116
Assets/Mirror/Authenticators/BasicAuthenticator.cs
Normal file
@ -0,0 +1,116 @@
|
||||
using UnityEngine;
|
||||
using System.Collections;
|
||||
|
||||
namespace Mirror.Authenticators
|
||||
{
|
||||
[AddComponentMenu("Network/Authenticators/BasicAuthenticator")]
|
||||
public class BasicAuthenticator : NetworkAuthenticator
|
||||
{
|
||||
[Header("Custom Properties")]
|
||||
|
||||
// set these in the inspector
|
||||
public string username;
|
||||
public string password;
|
||||
|
||||
public class AuthRequestMessage : MessageBase
|
||||
{
|
||||
// use whatever credentials make sense for your game
|
||||
// for example, you might want to pass the accessToken if using oauth
|
||||
public string authUsername;
|
||||
public string authPassword;
|
||||
}
|
||||
|
||||
public class AuthResponseMessage : MessageBase
|
||||
{
|
||||
public byte code;
|
||||
public string message;
|
||||
}
|
||||
|
||||
public override void OnStartServer()
|
||||
{
|
||||
// register a handler for the authentication request we expect from client
|
||||
NetworkServer.RegisterHandler<AuthRequestMessage>(OnAuthRequestMessage);
|
||||
}
|
||||
|
||||
public override void OnStartClient()
|
||||
{
|
||||
// register a handler for the authentication response we expect from server
|
||||
NetworkClient.RegisterHandler<AuthResponseMessage>(OnAuthResponseMessage);
|
||||
}
|
||||
|
||||
public override void OnServerAuthenticate(NetworkConnection conn)
|
||||
{
|
||||
// do nothing...wait for AuthRequestMessage from client
|
||||
}
|
||||
|
||||
public override void OnClientAuthenticate(NetworkConnection conn)
|
||||
{
|
||||
AuthRequestMessage authRequestMessage = new AuthRequestMessage
|
||||
{
|
||||
authUsername = username,
|
||||
authPassword = password
|
||||
};
|
||||
|
||||
NetworkClient.Send(authRequestMessage);
|
||||
}
|
||||
|
||||
public void OnAuthRequestMessage(NetworkConnection conn, AuthRequestMessage msg)
|
||||
{
|
||||
Debug.LogFormat("Authentication Request: {0} {1}", msg.authUsername, msg.authPassword);
|
||||
|
||||
// check the credentials by calling your web server, database table, playfab api, or any method appropriate.
|
||||
if (msg.authUsername == username && msg.authPassword == password)
|
||||
{
|
||||
// create and send msg to client so it knows to proceed
|
||||
AuthResponseMessage authResponseMessage = new AuthResponseMessage
|
||||
{
|
||||
code = 100,
|
||||
message = "Success"
|
||||
};
|
||||
|
||||
NetworkServer.SendToClient(conn.connectionId, authResponseMessage);
|
||||
|
||||
// Invoke the event to complete a successful authentication
|
||||
base.OnServerAuthenticated.Invoke(conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
// create and send msg to client so it knows to disconnect
|
||||
AuthResponseMessage authResponseMessage = new AuthResponseMessage
|
||||
{
|
||||
code = 200,
|
||||
message = "Invalid Credentials"
|
||||
};
|
||||
|
||||
NetworkServer.SendToClient(conn.connectionId, authResponseMessage);
|
||||
|
||||
// must set NetworkConnection isAuthenticated = false
|
||||
conn.isAuthenticated = false;
|
||||
|
||||
// disconnect the client after 1 second so that response message gets delivered
|
||||
Invoke(nameof(conn.Disconnect), 1);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnAuthResponseMessage(NetworkConnection conn, AuthResponseMessage msg)
|
||||
{
|
||||
if (msg.code == 100)
|
||||
{
|
||||
Debug.LogFormat("Authentication Response: {0}", msg.message);
|
||||
|
||||
// Invoke the event to complete a successful authentication
|
||||
base.OnClientAuthenticated.Invoke(conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogErrorFormat("Authentication Response: {0}", msg.message);
|
||||
|
||||
// Set this on the client for local reference
|
||||
conn.isAuthenticated = false;
|
||||
|
||||
// disconnect the client
|
||||
conn.Disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
Assets/Mirror/Authenticators/BasicAuthenticator.cs.meta
Normal file
11
Assets/Mirror/Authenticators/BasicAuthenticator.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28496b776660156428f00cf78289c1ec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
14
Assets/Mirror/Authenticators/Mirror.Authenticators.asmdef
Normal file
14
Assets/Mirror/Authenticators/Mirror.Authenticators.asmdef
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Mirror.Authenticators",
|
||||
"references": [
|
||||
"Mirror"
|
||||
],
|
||||
"optionalUnityReferences": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": []
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e720aa64e3f58fb4880566a322584340
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
90
Assets/Mirror/Runtime/NetworkAuthenticator.cs
Normal file
90
Assets/Mirror/Runtime/NetworkAuthenticator.cs
Normal file
@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Mirror
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity Event for the NetworkConnection
|
||||
/// </summary>
|
||||
[Serializable] public class UnityEventNetworkConnection : UnityEvent<NetworkConnection> { }
|
||||
|
||||
/// <summary>
|
||||
/// Base class for implementing component-based authentication during the Connect phase
|
||||
/// </summary>
|
||||
[HelpURL("https://mirror-networking.com/xmldocs/articles/Concepts/Authentication.html")]
|
||||
public abstract class NetworkAuthenticator : MonoBehaviour
|
||||
{
|
||||
[Header("Event Listeners (optional)")]
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers on the server when a client is authenticated
|
||||
/// </summary>
|
||||
[Tooltip("Mirror has an internal subscriber to this event. You can add your own here.")]
|
||||
public UnityEventNetworkConnection OnServerAuthenticated = new UnityEventNetworkConnection();
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers on the client when the client is authenticated
|
||||
/// </summary>
|
||||
[Tooltip("Mirror has an internal subscriber to this event. You can add your own here.")]
|
||||
public UnityEventNetworkConnection OnClientAuthenticated = new UnityEventNetworkConnection();
|
||||
|
||||
#region server
|
||||
|
||||
/// <summary>
|
||||
/// Called on server from StartServer to initialize the Authenticator
|
||||
/// <para>Server message handlers should be registered in this method.</para>
|
||||
/// </summary>
|
||||
public abstract void OnStartServer();
|
||||
|
||||
/// <summary>
|
||||
/// Called on client from StartClient to initialize the Authenticator
|
||||
/// <para>Client message handlers should be registered in this method.</para>
|
||||
/// </summary>
|
||||
public abstract void OnStartClient();
|
||||
|
||||
// This will get more code in the near future
|
||||
internal void OnServerAuthenticateInternal(NetworkConnection conn)
|
||||
{
|
||||
OnServerAuthenticate(conn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called on server from OnServerAuthenticateInternal when a client needs to authenticate
|
||||
/// </summary>
|
||||
/// <param name="conn">Connection to client.</param>
|
||||
public abstract void OnServerAuthenticate(NetworkConnection conn);
|
||||
|
||||
#endregion
|
||||
|
||||
#region client
|
||||
|
||||
// This will get more code in the near future
|
||||
internal void OnClientAuthenticateInternal(NetworkConnection conn)
|
||||
{
|
||||
OnClientAuthenticate(conn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called on client from OnClientAuthenticateInternal when a client needs to authenticate
|
||||
/// </summary>
|
||||
/// <param name="conn">Connection of the client.</param>
|
||||
public abstract void OnClientAuthenticate(NetworkConnection conn);
|
||||
|
||||
#endregion
|
||||
|
||||
void OnValidate()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// automatically assign NetworkManager field if we add this to NetworkManager
|
||||
NetworkManager manager = GetComponent<NetworkManager>();
|
||||
if (manager != null && manager.authenticator == null)
|
||||
{
|
||||
manager.authenticator = this;
|
||||
UnityEditor.Undo.RecordObject(gameObject, "Assigned NetworkManager authenticator");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
11
Assets/Mirror/Runtime/NetworkAuthenticator.cs.meta
Normal file
11
Assets/Mirror/Runtime/NetworkAuthenticator.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 407fc95d4a8257f448799f26cdde0c2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -32,6 +32,17 @@ public class NetworkConnection : IDisposable
|
||||
/// </remarks>
|
||||
public int connectionId = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Flag that indicates the client has been authenticated.
|
||||
/// </summary>
|
||||
public bool isAuthenticated;
|
||||
|
||||
/// <summary>
|
||||
/// General purpose object to hold authentication data, character selection, tokens, etc.
|
||||
/// associated with the connection for reference after Authentication completes.
|
||||
/// </summary>
|
||||
public object authenticationData;
|
||||
|
||||
/// <summary>
|
||||
/// Flag that tells if the connection has been marked as "ready" by a client calling ClientScene.Ready().
|
||||
/// <para>This property is read-only. It is set by the system on the client when ClientScene.Ready() is called, and set by the system on the server when a ready message is received from a client.</para>
|
||||
|
@ -92,6 +92,10 @@ public class NetworkManager : MonoBehaviour
|
||||
[FormerlySerializedAs("m_MaxConnections")]
|
||||
public int maxConnections = 4;
|
||||
|
||||
[Header("Authentication")]
|
||||
|
||||
public NetworkAuthenticator authenticator;
|
||||
|
||||
[Header("Spawn Info")]
|
||||
|
||||
/// <summary>
|
||||
@ -394,6 +398,12 @@ public bool StartServer()
|
||||
if (runInBackground)
|
||||
Application.runInBackground = true;
|
||||
|
||||
if (authenticator != null)
|
||||
{
|
||||
authenticator.OnStartServer();
|
||||
authenticator.OnServerAuthenticated.AddListener(OnServerAuthenticated);
|
||||
}
|
||||
|
||||
ConfigureServerFrameRate();
|
||||
|
||||
if (!NetworkServer.Listen(maxConnections))
|
||||
@ -461,6 +471,12 @@ public void StartClient()
|
||||
{
|
||||
InitializeSingleton();
|
||||
|
||||
if (authenticator != null)
|
||||
{
|
||||
authenticator.OnStartClient();
|
||||
authenticator.OnClientAuthenticated.AddListener(OnClientAuthenticated);
|
||||
}
|
||||
|
||||
if (runInBackground)
|
||||
Application.runInBackground = true;
|
||||
|
||||
@ -497,6 +513,13 @@ public virtual void StartHost()
|
||||
void ConnectLocalClient()
|
||||
{
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager StartHost");
|
||||
|
||||
if (authenticator != null)
|
||||
{
|
||||
authenticator.OnStartClient();
|
||||
authenticator.OnClientAuthenticated.AddListener(OnClientAuthenticated);
|
||||
}
|
||||
|
||||
networkAddress = "localhost";
|
||||
NetworkServer.ActivateLocalClientScene();
|
||||
NetworkClient.ConnectLocalServer();
|
||||
@ -522,6 +545,9 @@ public void StopServer()
|
||||
if (!NetworkServer.active)
|
||||
return;
|
||||
|
||||
if (authenticator != null)
|
||||
authenticator.OnServerAuthenticated.RemoveListener(OnServerAuthenticated);
|
||||
|
||||
OnStopServer();
|
||||
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager StopServer");
|
||||
@ -541,6 +567,9 @@ public void StopServer()
|
||||
/// </summary>
|
||||
public void StopClient()
|
||||
{
|
||||
if (authenticator != null)
|
||||
authenticator.OnClientAuthenticated.RemoveListener(OnClientAuthenticated);
|
||||
|
||||
OnStopClient();
|
||||
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager StopClient");
|
||||
@ -752,6 +781,27 @@ void OnServerConnectInternal(NetworkConnection conn, ConnectMessage connectMsg)
|
||||
{
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager.OnServerConnectInternal");
|
||||
|
||||
if (authenticator != null)
|
||||
{
|
||||
// we have an authenticator - let it handle authentication
|
||||
authenticator.OnServerAuthenticateInternal(conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
// authenticate immediately
|
||||
OnServerAuthenticated(conn);
|
||||
}
|
||||
}
|
||||
|
||||
// called after successful authentication
|
||||
void OnServerAuthenticated(NetworkConnection conn)
|
||||
{
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager.OnServerAuthenticated");
|
||||
|
||||
// set connection to authenticated
|
||||
conn.isAuthenticated = true;
|
||||
|
||||
// proceed with the login handshake by calling OnServerConnect
|
||||
if (networkSceneName != "" && networkSceneName != offlineScene)
|
||||
{
|
||||
SceneMessage msg = new SceneMessage() { sceneName = networkSceneName };
|
||||
@ -823,6 +873,27 @@ void OnClientConnectInternal(NetworkConnection conn, ConnectMessage message)
|
||||
{
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager.OnClientConnectInternal");
|
||||
|
||||
if (authenticator != null)
|
||||
{
|
||||
// we have an authenticator - let it handle authentication
|
||||
authenticator.OnClientAuthenticateInternal(conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
// authenticate immediately
|
||||
OnClientAuthenticated(conn);
|
||||
}
|
||||
}
|
||||
|
||||
// called after successful authentication
|
||||
void OnClientAuthenticated(NetworkConnection conn)
|
||||
{
|
||||
if (LogFilter.Debug) Debug.Log("NetworkManager.OnClientAuthenticated");
|
||||
|
||||
// set connection to authenticated
|
||||
conn.isAuthenticated = true;
|
||||
|
||||
// proceed with the login handshake by calling OnClientConnect
|
||||
string loadedSceneName = SceneManager.GetActiveScene().name;
|
||||
if (string.IsNullOrEmpty(onlineScene) || onlineScene == offlineScene || loadedSceneName == onlineScene)
|
||||
{
|
||||
|
@ -16,67 +16,45 @@ When you have a multiplayer game, often you need to store information about your
|
||||
|
||||
- Use a web service in your website
|
||||
|
||||
Trying to write a comprehensive authentication framework that cover all these is very complex. There is no one size fit all, and we would quickly end up with bloated code.
|
||||
Mirror includes an `Authenticator` abstract class that allows you to implement any authentication scheme you need.
|
||||
|
||||
Instead, **Mirror does not perform authentication**, but we provide hooks you can use to implement any of these.
|
||||
## Encryption Warning
|
||||
|
||||
Here is an example of how to implement simple username/password authentication:
|
||||
By default Mirror uses Telepathy, which is not encrypted, so if you want to do authentication through Mirror, we highly recommend you use a transport that supports encryption.
|
||||
|
||||
1. Select your `NetworkManager` game object in the unity editor.
|
||||
## Custom Authenticators
|
||||
|
||||
2. In the inspector, under `Spawn Info`, disable `Auto Create Player`
|
||||
To make your own custom Authenticator, you can just create a new script in your project (not in the Mirror folders) that inherits from `Authenticator` and override the methods as needed.
|
||||
|
||||
3. Call `AddPlayer` in your client to pass the credentials.
|
||||
- When a client is authenticated to your satisfaction, you simply call `base.OnServerAuthenticated.Invoke(conn)` and `base.OnClientAuthenticated.Invoke(conn)` on the server and client, respectively. Mirror is listening for these events to proceed with the connection sequence.
|
||||
|
||||
4. Override the `OnServerAddPlayer` method and validate the user's credential.
|
||||
- In the inspector you can optionally subscribe your own methods to the OnServerAuthenticated and OnClientAuthenticated events.
|
||||
|
||||
For example this would be part of your `NetworkManager` class:
|
||||
Here are some tips for custom Authenticators:
|
||||
|
||||
```cs
|
||||
class MyGameNetworkManager : NetworkManager
|
||||
{
|
||||
class CredentialsMessage : MessageBase
|
||||
{
|
||||
// use whatever credentials make sense for your game
|
||||
// for example, you might want to pass the accessToken if using oauth
|
||||
public string username;
|
||||
public string password;
|
||||
}
|
||||
- `OnStartServer` and `OnStartClient` are the appropriate methods to register server and client messages and their handlers. They're called from StartServer/StartHost, and StartClient, respectively.
|
||||
|
||||
// this gets called on the client after it has connected to the server
|
||||
public override void OnClientConnect(NetworkConnection conn)
|
||||
{
|
||||
base.OnClientConnect(conn);
|
||||
- Send a message to the client if authentication fails, especially if there's some issue they can resolve.
|
||||
|
||||
CredentialsMessage msg = new CredentialsMessage()
|
||||
{
|
||||
// perhaps get the username and password from textboxes instead
|
||||
username = "Joe",
|
||||
password = "Gaba Gaba"
|
||||
};
|
||||
- Call the `Disconnect()` method of the `NetworkConnection` on the server and client when authentication fails. If you want to give the user a few tries to get their credentials right, you certainly can, but Mirror will not do the disconnect for you.
|
||||
|
||||
ClientScene.AddPlayer(conn, MessagePacker.Pack(msg));
|
||||
}
|
||||
- Remember to put a small delay on the Disconnect call on the server if you send a failure message so that it has a chance to be delivered before the connection is dropped.
|
||||
|
||||
// this gets called in your server when the client requests to add a player.
|
||||
public override void OnServerAddPlayer(NetworkConnection conn, AddPlayerMessage extraMessage)
|
||||
{
|
||||
CredentialsMessage msg = MessagePacker.Unpack<CredentialsMessage>(extraMessage.value);
|
||||
- `NetworkConnection` has an `AuthenticationData` object where you can drop a class instance of any data you need to persist on the server related to the authentication, such as account id's, tokens, character selection, etc.
|
||||
|
||||
// check the credentials by calling your web server, database table, playfab api, or any method appropriate.
|
||||
if (msg.username == "Joe" && msg.password == "Gaba Gaba")
|
||||
{
|
||||
// proceed to regular add player
|
||||
base.OnServerAddPlayer(conn, extraMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
conn.Disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Now that you have the foundation of a custom Authenticator component, the rest is up to you. You can exchange any number of custom messages between the server and client as necessary to complete your authentication process before approving the client.
|
||||
|
||||
## Warning
|
||||
## Basic Authenticator
|
||||
|
||||
Mirror includes a Basic Authenticator in the Mirror / Authenticators folder which just uses a simple username and password.
|
||||
|
||||
- Drag the Basic Authenticator script to the inspector of the object in your scene that has Network Manager
|
||||
|
||||
- The Basic Authenticator component will automatically be assigned to the Authenticator field in Network Manager
|
||||
|
||||
When you're done, it should look like this:
|
||||
|
||||
![Inspector showing Basic Authentication component](BasicAuthentication.PNG)
|
||||
|
||||
> **Note:** You don't need to assign anything to the event lists unless you want to subscribe to the events in your own code for your own purposes. Mirror has internal listeners for both events.
|
||||
|
||||
By default Mirror uses Telepathy, which is not encrypted. The above code sample works, but if you want to do authentication through Mirror, we highly recommend you use a transport that supports encryption.
|
||||
|
BIN
doc/articles/Concepts/BasicAuthentication.PNG
Normal file
BIN
doc/articles/Concepts/BasicAuthentication.PNG
Normal file
Binary file not shown.
After Width: | Height: | Size: 57 KiB |
Loading…
Reference in New Issue
Block a user