From 035d29fb357006c29ffb40e0a53c1e999237cdd1 Mon Sep 17 00:00:00 2001
From: Matt Montgomery <33811686+ConfusedPolarBear@users.noreply.github.com>
Date: Thu, 13 Aug 2020 15:35:04 -0500
Subject: Migrate to new API standard
---
Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
(limited to 'Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs')
diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
new file mode 100644
index 000000000..8f53d5f37
--- /dev/null
+++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
@@ -0,0 +1,13 @@
+namespace Jellyfin.Api.Models.UserDtos
+{
+ ///
+ /// The quick connect request body.
+ ///
+ public class QuickConnectDto
+ {
+ ///
+ /// Gets or sets the quick connect token.
+ ///
+ public string? Token { get; set; }
+ }
+}
--
cgit v1.2.3
From eaa57115347f6f70d478f2ca39601d2e70efbdaf Mon Sep 17 00:00:00 2001
From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com>
Date: Sun, 16 Aug 2020 17:21:08 -0500
Subject: Apply suggestions from code review
Co-authored-by: Cody Robibero
---
Jellyfin.Api/Controllers/QuickConnectController.cs | 15 +++++----------
Jellyfin.Api/Controllers/UserController.cs | 5 -----
Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs | 1 +
3 files changed, 6 insertions(+), 15 deletions(-)
(limited to 'Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs')
diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs
index d45ea058d..fd5453595 100644
--- a/Jellyfin.Api/Controllers/QuickConnectController.cs
+++ b/Jellyfin.Api/Controllers/QuickConnectController.cs
@@ -46,7 +46,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult GetStatus()
{
_quickConnect.ExpireRequests();
- return Ok(_quickConnect.State);
+ return _quickConnect.State;
}
///
@@ -60,7 +60,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult Initiate([FromQuery] string? friendlyName)
{
- return Ok(_quickConnect.TryConnect(friendlyName));
+ return _quickConnect.TryConnect(friendlyName);
}
///
@@ -78,7 +78,7 @@ namespace Jellyfin.Api.Controllers
try
{
var result = _quickConnect.CheckRequestStatus(secret);
- return Ok(result);
+ return result;
}
catch (ResourceNotFoundException)
{
@@ -135,12 +135,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult Authorize([FromQuery, Required] string? code)
{
- if (code == null)
- {
- return BadRequest("Missing code");
- }
-
- return Ok(_quickConnect.AuthorizeRequest(Request, code));
+ return _quickConnect.AuthorizeRequest(Request, code);
}
///
@@ -153,7 +148,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult Deauthorize()
{
- var userId = _authContext.GetAuthorizationInfo(Request).UserId;
+ var userId = ClaimHelpers.GetUserId(request.HttpContext.User);
return _quickConnect.DeleteAllDevices(userId);
}
}
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index 131fffb7a..355816bd3 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -227,11 +227,6 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
{
- if (request.Token == null)
- {
- return BadRequest("Access token is required.");
- }
-
var auth = _authContext.GetAuthorizationInfo(Request);
try
diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
index 8f53d5f37..ac0949732 100644
--- a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
+++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
@@ -8,6 +8,7 @@
///
/// Gets or sets the quick connect token.
///
+ [Required]
public string? Token { get; set; }
}
}
--
cgit v1.2.3
From c49a357f85edbabab11b61b9d4a2938bdb8f3df9 Mon Sep 17 00:00:00 2001
From: Matt Montgomery <33811686+ConfusedPolarBear@users.noreply.github.com>
Date: Sun, 16 Aug 2020 17:45:53 -0500
Subject: Fix compile errors
---
Jellyfin.Api/Controllers/QuickConnectController.cs | 10 ++++++++--
Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs | 4 +++-
2 files changed, 11 insertions(+), 3 deletions(-)
(limited to 'Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs')
diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs
index fd5453595..1625bcffe 100644
--- a/Jellyfin.Api/Controllers/QuickConnectController.cs
+++ b/Jellyfin.Api/Controllers/QuickConnectController.cs
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
@@ -148,8 +149,13 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult Deauthorize()
{
- var userId = ClaimHelpers.GetUserId(request.HttpContext.User);
- return _quickConnect.DeleteAllDevices(userId);
+ var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
+ if (!userId.HasValue)
+ {
+ return 0;
+ }
+
+ return _quickConnect.DeleteAllDevices(userId.Value);
}
}
}
diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
index ac0949732..c3a2d5cec 100644
--- a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
+++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
@@ -1,4 +1,6 @@
-namespace Jellyfin.Api.Models.UserDtos
+using System.ComponentModel.DataAnnotations;
+
+namespace Jellyfin.Api.Models.UserDtos
{
///
/// The quick connect request body.
--
cgit v1.2.3
From 397868be95db2f705522cc975ac076e60decbf0f Mon Sep 17 00:00:00 2001
From: crobibero
Date: Wed, 23 Jun 2021 21:07:08 -0600
Subject: Fix issues with QuickConnect and AuthenticationDb
---
.../QuickConnect/QuickConnectManager.cs | 85 +++++++++++++++++++---
.../Session/SessionManager.cs | 13 +++-
Jellyfin.Api/Controllers/QuickConnectController.cs | 11 ++-
Jellyfin.Api/Controllers/UserController.cs | 23 ++----
Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs | 4 +-
.../QuickConnect/IQuickConnect.cs | 13 +++-
MediaBrowser.Controller/Session/ISessionManager.cs | 7 +-
.../QuickConnect/QuickConnectResult.cs | 40 ++++++++--
8 files changed, 148 insertions(+), 48 deletions(-)
(limited to 'Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs')
diff --git a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
index afc08fc26..ae773c658 100644
--- a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
+++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.QuickConnect;
@@ -29,8 +30,9 @@ namespace Emby.Server.Implementations.QuickConnect
///
private const int Timeout = 10;
- private readonly RNGCryptoServiceProvider _rng = new();
- private readonly ConcurrentDictionary _currentRequests = new();
+ private readonly RNGCryptoServiceProvider _rng = new ();
+ private readonly ConcurrentDictionary _currentRequests = new ();
+ private readonly ConcurrentDictionary _authorizedSecrets = new ();
private readonly IServerConfigurationManager _config;
private readonly ILogger _logger;
@@ -68,14 +70,41 @@ namespace Emby.Server.Implementations.QuickConnect
}
///
- public QuickConnectResult TryConnect()
+ public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo)
{
+ if (string.IsNullOrEmpty(authorizationInfo.DeviceId))
+ {
+ throw new ArgumentException(nameof(authorizationInfo.DeviceId) + " is required");
+ }
+
+ if (string.IsNullOrEmpty(authorizationInfo.Device))
+ {
+ throw new ArgumentException(nameof(authorizationInfo.Device) + " is required");
+ }
+
+ if (string.IsNullOrEmpty(authorizationInfo.Client))
+ {
+ throw new ArgumentException(nameof(authorizationInfo.Client) + " is required");
+ }
+
+ if (string.IsNullOrEmpty(authorizationInfo.Version))
+ {
+ throw new ArgumentException(nameof(authorizationInfo.Version) + "is required");
+ }
+
AssertActive();
ExpireRequests();
var secret = GenerateSecureRandom();
var code = GenerateCode();
- var result = new QuickConnectResult(secret, code, DateTime.UtcNow);
+ var result = new QuickConnectResult(
+ secret,
+ code,
+ DateTime.UtcNow,
+ authorizationInfo.DeviceId,
+ authorizationInfo.Device,
+ authorizationInfo.Client,
+ authorizationInfo.Version);
_currentRequests[code] = result;
return result;
@@ -135,19 +164,41 @@ namespace Emby.Server.Implementations.QuickConnect
throw new InvalidOperationException("Request is already authorized");
}
- var token = Guid.NewGuid();
- result.Authentication = token;
-
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
- result.DateAdded = DateTime.Now.Add(TimeSpan.FromMinutes(1));
+ result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1));
- await _sessionManager.AuthenticateQuickConnect(userId).ConfigureAwait(false);
+ var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
+ {
+ UserId = userId,
+ DeviceId = result.DeviceId,
+ DeviceName = result.DeviceName,
+ App = result.AppName,
+ AppVersion = result.AppVersion
+ }).ConfigureAwait(false);
+
+ _authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult);
+ result.Authenticated = true;
+ _currentRequests[code] = result;
- _logger.LogDebug("Authorizing device with code {Code} to login as user {userId}", code, userId);
+ _logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
return true;
}
+ ///
+ public AuthenticationResult GetAuthorizedRequest(string secret)
+ {
+ AssertActive();
+ ExpireRequests();
+
+ if (!_authorizedSecrets.TryGetValue(secret, out var result))
+ {
+ throw new ResourceNotFoundException("Unable to find request");
+ }
+
+ return result.AuthenticationResult;
+ }
+
///
/// Dispose.
///
@@ -189,7 +240,7 @@ namespace Emby.Server.Implementations.QuickConnect
// Expire stale connection requests
foreach (var (_, currentRequest) in _currentRequests)
{
- if (expireAll || currentRequest.DateAdded > minTime)
+ if (expireAll || currentRequest.DateAdded < minTime)
{
var code = currentRequest.Code;
_logger.LogDebug("Removing expired request {Code}", code);
@@ -200,6 +251,18 @@ namespace Emby.Server.Implementations.QuickConnect
}
}
}
+
+ foreach (var (secret, (timestamp, _)) in _authorizedSecrets)
+ {
+ if (expireAll || timestamp < minTime)
+ {
+ _logger.LogDebug("Removing expired secret {Secret}", secret);
+ if (!_authorizedSecrets.TryRemove(secret, out _))
+ {
+ _logger.LogWarning("Secret {Secret} already expired", secret);
+ }
+ }
+ }
}
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 29b545583..40a346e95 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -1432,16 +1432,21 @@ namespace Emby.Server.Implementations.Session
///
/// Authenticates the new session.
///
- /// The request.
- /// Task{SessionInfo}.
+ /// The authenticationrequest.
+ /// The authentication result.
public Task AuthenticateNewSession(AuthenticationRequest request)
{
return AuthenticateNewSessionInternal(request, true);
}
- public Task AuthenticateQuickConnect(Guid userId)
+ ///
+ /// Directly authenticates the session without enforcing password.
+ ///
+ /// The authentication request.
+ /// The authentication result.
+ public Task AuthenticateDirect(AuthenticationRequest request)
{
- return AuthenticateNewSessionInternal(new AuthenticationRequest { UserId = userId }, false);
+ return AuthenticateNewSessionInternal(request, false);
}
private async Task AuthenticateNewSessionInternal(AuthenticationRequest request, bool enforcePassword)
diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs
index 56fef08a9..87b78fe93 100644
--- a/Jellyfin.Api/Controllers/QuickConnectController.cs
+++ b/Jellyfin.Api/Controllers/QuickConnectController.cs
@@ -4,6 +4,7 @@ using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.AspNetCore.Authorization;
@@ -18,14 +19,17 @@ namespace Jellyfin.Api.Controllers
public class QuickConnectController : BaseJellyfinApiController
{
private readonly IQuickConnect _quickConnect;
+ private readonly IAuthorizationContext _authContext;
///
/// Initializes a new instance of the class.
///
/// Instance of the interface.
- public QuickConnectController(IQuickConnect quickConnect)
+ /// Instance of the interface.
+ public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext)
{
_quickConnect = quickConnect;
+ _authContext = authContext;
}
///
@@ -48,11 +52,12 @@ namespace Jellyfin.Api.Controllers
/// A with a secret and code for future use or an error message.
[HttpGet("Initiate")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult Initiate()
+ public async Task> Initiate()
{
try
{
- return _quickConnect.TryConnect();
+ var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
+ return _quickConnect.TryConnect(auth);
}
catch (AuthenticationException)
{
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index 8e2298bb7..4263d4fe5 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -14,6 +14,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
@@ -38,6 +39,7 @@ namespace Jellyfin.Api.Controllers
private readonly IAuthorizationContext _authContext;
private readonly IServerConfigurationManager _config;
private readonly ILogger _logger;
+ private readonly IQuickConnect _quickConnectManager;
///
/// Initializes a new instance of the class.
@@ -49,6 +51,7 @@ namespace Jellyfin.Api.Controllers
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
+ /// Instance of the interface.
public UserController(
IUserManager userManager,
ISessionManager sessionManager,
@@ -56,7 +59,8 @@ namespace Jellyfin.Api.Controllers
IDeviceManager deviceManager,
IAuthorizationContext authContext,
IServerConfigurationManager config,
- ILogger logger)
+ ILogger logger,
+ IQuickConnect quickConnectManager)
{
_userManager = userManager;
_sessionManager = sessionManager;
@@ -65,6 +69,7 @@ namespace Jellyfin.Api.Controllers
_authContext = authContext;
_config = config;
_logger = logger;
+ _quickConnectManager = quickConnectManager;
}
///
@@ -228,23 +233,11 @@ namespace Jellyfin.Api.Controllers
/// A containing an with information about the new session.
[HttpPost("AuthenticateWithQuickConnect")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
+ public ActionResult AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
{
- var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
-
try
{
- var authRequest = new AuthenticationRequest
- {
- App = auth.Client,
- AppVersion = auth.Version,
- DeviceId = auth.DeviceId,
- DeviceName = auth.Device,
- };
-
- return await _sessionManager.AuthenticateQuickConnect(
- authRequest,
- request.Token).ConfigureAwait(false);
+ return _quickConnectManager.GetAuthorizedRequest(request.Secret);
}
catch (SecurityException e)
{
diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
index c3a2d5cec..9493c08c2 100644
--- a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
+++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs
@@ -8,9 +8,9 @@ namespace Jellyfin.Api.Models.UserDtos
public class QuickConnectDto
{
///
- /// Gets or sets the quick connect token.
+ /// Gets or sets the quick connect secret.
///
[Required]
- public string? Token { get; set; }
+ public string Secret { get; set; } = null!;
}
}
diff --git a/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs b/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs
index 616409533..ec3706773 100644
--- a/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs
+++ b/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
-using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Model.QuickConnect;
namespace MediaBrowser.Controller.QuickConnect
@@ -18,8 +19,9 @@ namespace MediaBrowser.Controller.QuickConnect
///
/// Initiates a new quick connect request.
///
+ /// The initiator authorization info.
/// A quick connect result with tokens to proceed or throws an exception if not active.
- QuickConnectResult TryConnect();
+ QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo);
///
/// Checks the status of an individual request.
@@ -35,5 +37,12 @@ namespace MediaBrowser.Controller.QuickConnect
/// Identifying code for the request.
/// A boolean indicating if the authorization completed successfully.
Task AuthorizeRequest(Guid userId, string code);
+
+ ///
+ /// Gets the authorized request for the secret.
+ ///
+ /// The secret.
+ /// The authentication result.
+ AuthenticationResult GetAuthorizedRequest(string secret);
}
}
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index 8be9ff521..88a905166 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -273,12 +273,7 @@ namespace MediaBrowser.Controller.Session
/// Task{SessionInfo}.
Task AuthenticateNewSession(AuthenticationRequest request);
- ///
- /// Authenticates a new session with quick connect.
- ///
- /// The user id.
- /// Task{SessionInfo}.
- Task AuthenticateQuickConnect(Guid userId);
+ Task AuthenticateDirect(AuthenticationRequest request);
///
/// Reports the capabilities.
diff --git a/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs b/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs
index d180d2986..35a82f47c 100644
--- a/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs
+++ b/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs
@@ -13,17 +13,32 @@ namespace MediaBrowser.Model.QuickConnect
/// The secret used to query the request state.
/// The code used to allow the request.
/// The time when the request was created.
- public QuickConnectResult(string secret, string code, DateTime dateAdded)
+ /// The requesting device id.
+ /// The requesting device name.
+ /// The requesting app name.
+ /// The requesting app version.
+ public QuickConnectResult(
+ string secret,
+ string code,
+ DateTime dateAdded,
+ string deviceId,
+ string deviceName,
+ string appName,
+ string appVersion)
{
Secret = secret;
Code = code;
DateAdded = dateAdded;
+ DeviceId = deviceId;
+ DeviceName = deviceName;
+ AppName = appName;
+ AppVersion = appVersion;
}
///
- /// Gets a value indicating whether this request is authorized.
+ /// Gets or sets a value indicating whether this request is authorized.
///
- public bool Authenticated => Authentication != null;
+ public bool Authenticated { get; set; }
///
/// Gets the secret value used to uniquely identify this request. Can be used to retrieve authentication information.
@@ -36,9 +51,24 @@ namespace MediaBrowser.Model.QuickConnect
public string Code { get; }
///
- /// Gets or sets the private access token.
+ /// Gets the requesting device id.
///
- public Guid? Authentication { get; set; }
+ public string DeviceId { get; }
+
+ ///
+ /// Gets the requesting device name.
+ ///
+ public string DeviceName { get; }
+
+ ///
+ /// Gets the requesting app name.
+ ///
+ public string AppName { get; }
+
+ ///
+ /// Gets the requesting app version.
+ ///
+ public string AppVersion { get; }
///
/// Gets or sets the DateTime that this request was created.
--
cgit v1.2.3