feat(auth): Added authentication

This commit is contained in:
Fishandchips321 2026-03-05 12:34:55 +00:00
parent d85d4334f8
commit 5e100c75ed
39 changed files with 704 additions and 86 deletions

View file

@ -0,0 +1,32 @@
using JellyGlass.Exceptions;
using JellyGlass.Services;
using Microsoft.AspNetCore.Mvc;
namespace JellyGlass.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private IAuthService _service;
public AuthController(IAuthService service)
{
_service = service;
}
[HttpPost]
public async Task<IActionResult> Authenticate([FromForm] string username, [FromForm] string password)
{
try
{
var session = await _service.AuthenticateUser(username, password);
return Ok(session);
}
catch (LoginFailedException)
{
return Unauthorized();
}
}
}

View file

@ -1,51 +0,0 @@
using JellyGlass.Models;
using JellyGlass.Services;
using Microsoft.AspNetCore.Mvc;
namespace JellyGlass.Controllers;
[ApiController]
[Route("/api/[controller]")]
public class LibraryController : ControllerBase
{
private ILogger<LibraryController> _logger;
private ILibraryService _service;
public LibraryController(ILogger<LibraryController> logger, ILibraryService service)
{
_logger = logger;
_service = service;
}
[HttpGet()]
public async Task<IActionResult> GetLibrares()
{
var libraries = await _service.GetLibraries();
return Ok(libraries);
}
[HttpGet("{libraryName}")]
public async Task<IActionResult> GetLibraryItems([FromRoute] string libraryName)
{
throw new NotImplementedException();
}
// [HttpGet("{libraryName}/Item/{itemName}")]
// public async Task<IActionResult> GetLibraryItem([FromRoute] string libraryName, string itemName)
// {
// }
// [HttpGet("TvShows/{seriesName}")]
// public async Task<IActionResult> GetSeasonsForTvSeries([FromRoute] string seriesName)
// {
// }
// [HttpGet("TvShows/{seriesName}/Season/{seasonName}")]
// public async Task<IActionResult> GetEpisodesForTvSeriesSeason([FromRoute] string seriesName, string seasonName)
// {
// }
}

View file

@ -10,16 +10,25 @@ public class SearchController : ControllerBase
{
private ILogger<SearchController> _logger;
private ISearchService _service;
private IAuthService _auth;
public SearchController(ILogger<SearchController> logger, ISearchService service)
public SearchController(ILogger<SearchController> logger, ISearchService service, IAuthService auth)
{
_logger = logger;
_service = service;
_auth = auth;
}
[HttpGet]
public async Task<IActionResult> handleSearch([FromQuery] string searchTerm, string serverId)
{
var sessionToken = Request.Cookies["session"];
if (!await _auth.IsAuthenticated(sessionToken))
{
return Unauthorized();
}
var decodedSearchTerm = HttpUtility.UrlDecode(searchTerm);
var results = await _service.Search(decodedSearchTerm, serverId);

View file

@ -8,18 +8,27 @@ namespace JellyGlass.Controllers;
public class ServersController : ControllerBase
{
private ILogger<ServersController> _logger;
private IServerService _service;
private IServerService _serverService;
private IAuthService _authService;
public ServersController(ILogger<ServersController> logger, IServerService service)
public ServersController(ILogger<ServersController> logger, IServerService serverService, IAuthService authService)
{
_logger = logger;
_service = service;
_serverService = serverService;
_authService = authService;
}
[HttpGet]
public async Task<IActionResult> getServers()
{
var servers = await _service.GetServers();
var sessionToken = Request.Cookies["session"];
if (!await _authService.IsAuthenticated(sessionToken))
{
return Unauthorized();
}
var servers = await _serverService.GetServers();
return Ok(servers);
}

View file

@ -0,0 +1,16 @@
namespace JellyGlass.Exceptions;
public class AuthRepositoryException : System.Exception
{
public AuthRepositoryException() { }
public AuthRepositoryException(string message) : base(message) { }
public AuthRepositoryException(string message, System.Exception inner) : base(message, inner) { }
}
[System.Serializable]
public class AuthNotFoundException : AuthRepositoryException
{
public AuthNotFoundException() { }
public AuthNotFoundException(string message) : base(message) { }
public AuthNotFoundException(string message, System.Exception inner) : base(message, inner) { }
}

View file

@ -0,0 +1,16 @@
namespace JellyGlass.Exceptions;
public class AuthServiceException : Exception
{
public AuthServiceException() { }
public AuthServiceException(string message) : base(message) { }
public AuthServiceException(string message, Exception inner) : base(message, inner) { }
}
[System.Serializable]
public class LoginFailedException : AuthServiceException
{
public LoginFailedException() { }
public LoginFailedException(string message) : base(message) { }
public LoginFailedException(string message, System.Exception inner) : base(message, inner) { }
}

View file

@ -0,0 +1,15 @@
namespace JellyGlass.Exceptions;
public class SessionRepositoryException : Exception
{
public SessionRepositoryException() { }
public SessionRepositoryException(string message) : base(message) { }
public SessionRepositoryException(string message, Exception inner) : base(message, inner) { }
}
public class SessionNotFoundException : SessionRepositoryException
{
public SessionNotFoundException() : base() { }
public SessionNotFoundException(string message) : base(message) { }
public SessionNotFoundException(string message, Exception inner) : base(message, inner) { }
}

View file

@ -1,18 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" />
</ItemGroup>
</Project>
</Project>

View file

@ -0,0 +1,88 @@
// <auto-generated />
using System;
using JellyGlass.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JellyGlassBackend.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260303190433_auth")]
partial class auth
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("JellyGlass.Models.Server", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ApiToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Servers");
});
modelBuilder.Entity("JellyGlass.Models.UserLogin", b =>
{
b.Property<string>("Username")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Username");
b.ToTable("Logins");
});
modelBuilder.Entity("JellyGlass.Models.UserSession", b =>
{
b.Property<string>("SessionToken")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresOn")
.HasColumnType("TEXT");
b.Property<string>("LoginUsername")
.HasColumnType("TEXT");
b.HasKey("SessionToken");
b.HasIndex("LoginUsername");
b.ToTable("Sessions");
});
modelBuilder.Entity("JellyGlass.Models.UserSession", b =>
{
b.HasOne("JellyGlass.Models.UserLogin", "Login")
.WithMany()
.HasForeignKey("LoginUsername");
b.Navigation("Login");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JellyGlassBackend.Migrations
{
/// <inheritdoc />
public partial class auth : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Logins",
columns: table => new
{
Username = table.Column<string>(type: "TEXT", nullable: false),
HashedPassword = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Logins", x => x.Username);
});
migrationBuilder.CreateTable(
name: "Sessions",
columns: table => new
{
SessionToken = table.Column<string>(type: "TEXT", nullable: false),
LoginUsername = table.Column<string>(type: "TEXT", nullable: true),
ExpiresOn = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sessions", x => x.SessionToken);
table.ForeignKey(
name: "FK_Sessions_Logins_LoginUsername",
column: x => x.LoginUsername,
principalTable: "Logins",
principalColumn: "Username");
});
migrationBuilder.CreateIndex(
name: "IX_Sessions_LoginUsername",
table: "Sessions",
column: "LoginUsername");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Sessions");
migrationBuilder.DropTable(
name: "Logins");
}
}
}

View file

@ -1,4 +1,5 @@
// <auto-generated />
using System;
using JellyGlass.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@ -37,6 +38,47 @@ namespace JellyGlassBackend.Migrations
b.ToTable("Servers");
});
modelBuilder.Entity("JellyGlass.Models.UserLogin", b =>
{
b.Property<string>("Username")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Username");
b.ToTable("Logins");
});
modelBuilder.Entity("JellyGlass.Models.UserSession", b =>
{
b.Property<string>("SessionToken")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresOn")
.HasColumnType("TEXT");
b.Property<string>("LoginUsername")
.HasColumnType("TEXT");
b.HasKey("SessionToken");
b.HasIndex("LoginUsername");
b.ToTable("Sessions");
});
modelBuilder.Entity("JellyGlass.Models.UserSession", b =>
{
b.HasOne("JellyGlass.Models.UserLogin", "Login")
.WithMany()
.HasForeignKey("LoginUsername");
b.Navigation("Login");
});
#pragma warning restore 612, 618
}
}

View file

@ -0,0 +1,7 @@
namespace JellyGlass.Models;
public class LoginDTO
{
public required string Username { get; set; }
public required string Password { get; set; }
}

View file

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace JellyGlass.Models;
public class UserLogin
{
[Key]
public required string Username { get; set; }
public required string HashedPassword { get; set; }
}

View file

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace JellyGlass.Models;
public class UserSession
{
public required UserLogin Login { get; set; }
[Key]
public required string SessionToken { get; set; }
public required DateTime ExpiresOn { get; set; }
}

View file

@ -0,0 +1,13 @@
namespace JellyGlass.Models;
public class UserSessionDTO
{
public UserSessionDTO(UserSession session)
{
SessionToken = session.SessionToken;
ExpiresOn = session.ExpiresOn;
}
public string SessionToken { get; set; }
public DateTime ExpiresOn { get; set; }
}

View file

@ -24,11 +24,15 @@ else
builder.Services.AddSqlite<DatabaseContext>(dbConnectionString);
builder.Services.AddTransient<ILibraryService, LibraryService>();
builder.Services.AddTransient<IServerRepository, ServerRepository>();
builder.Services.AddTransient<ILoginRepository, LoginRepository>();
builder.Services.AddTransient<ISessionRepository, SessionRepository>();
builder.Services.AddTransient<ILibraryService, LibraryService>();
builder.Services.AddScoped<IClientService, ClientService>();
builder.Services.AddTransient<IServerService, ServerService>();
builder.Services.AddTransient<ISearchService, SearchService>();
builder.Services.AddTransient<IAuthService, AuthService>();
var app = builder.Build();

View file

@ -5,11 +5,13 @@ namespace JellyGlass.Repositories;
public class DatabaseContext : DbContext
{
public DatabaseContext(DbContextOptions options)
public DatabaseContext(DbContextOptions<DatabaseContext> options)
: base(options)
{
}
public DbSet<Server> Servers { get; set; }
public DbSet<UserLogin> Logins { get; set; }
public DbSet<UserSession> Sessions { get; set; }
}

View file

@ -0,0 +1,11 @@
using JellyGlass.Models;
namespace JellyGlass.Repositories;
public interface ISessionRepository
{
public Task<UserSession> CreateUserSession(UserLogin user);
public Task<UserSession?> GetUserSession(string sessionToken);
public Task RefreshSessionExpiry(string sessionToken);
public Task DeleteSession(string sessionToken);
}

View file

@ -0,0 +1,8 @@
using JellyGlass.Models;
namespace JellyGlass.Repositories;
public interface ILoginRepository
{
public Task<UserLogin> GetUserLogin(string username);
}

View file

@ -0,0 +1,27 @@
using JellyGlass.Exceptions;
using JellyGlass.Models;
using Microsoft.EntityFrameworkCore;
namespace JellyGlass.Repositories;
public class LoginRepository : ILoginRepository
{
private DatabaseContext _context;
public LoginRepository(DatabaseContext context)
{
_context = context;
}
public async Task<UserLogin> GetUserLogin(string username)
{
var login = await _context.Logins.FirstOrDefaultAsync(l => l.Username == username);
if (login == null)
{
throw new AuthNotFoundException($"Login {username} doesn't exist");
}
return login;
}
}

View file

@ -0,0 +1,68 @@
using JellyGlass.Exceptions;
using JellyGlass.Models;
using Microsoft.EntityFrameworkCore;
namespace JellyGlass.Repositories;
public class SessionRepository : ISessionRepository
{
private DatabaseContext _context;
public SessionRepository(DatabaseContext context)
{
_context = context;
}
public async Task<UserSession> CreateUserSession(UserLogin user)
{
var newSession = new UserSession()
{
Login = user,
SessionToken = Guid.NewGuid().ToString(),
ExpiresOn = GenerateExpiryDate()
};
await _context.Sessions.AddAsync(newSession);
await _context.SaveChangesAsync();
return newSession;
}
public async Task<UserSession?> GetUserSession(string sessionToken)
{
var session = await _context.Sessions.FirstOrDefaultAsync(s => s.SessionToken == sessionToken);
return session;
}
public async Task RefreshSessionExpiry(string sessionToken)
{
var session = await GetUserSession(sessionToken);
if (session == null)
{
throw new SessionNotFoundException($"Couldn't find session {sessionToken} while trying ti refresh token");
}
session.ExpiresOn = GenerateExpiryDate();
_context.Sessions.Update(session);
await _context.SaveChangesAsync();
}
public async Task DeleteSession(string sessionToken)
{
var session = await GetUserSession(sessionToken);
if (session != null)
{
_context.Sessions.Remove(session);
await _context.SaveChangesAsync();
}
}
private static DateTime GenerateExpiryDate()
{
return DateTime.Now + TimeSpan.FromDays(30); //TODO: Make this configurable
}
}

View file

@ -0,0 +1,67 @@
using JellyGlass.Exceptions;
using JellyGlass.Models;
using JellyGlass.Repositories;
namespace JellyGlass.Services;
public class AuthService : IAuthService
{
private ILoginRepository _loginRepo;
private ISessionRepository _sessionRepo;
public AuthService(ILoginRepository loginRepo, ISessionRepository sessionRepo)
{
_loginRepo = loginRepo;
_sessionRepo = sessionRepo;
}
public async Task<UserSessionDTO> AuthenticateUser(string username, string password)
{
UserLogin login;
try
{
login = await _loginRepo.GetUserLogin(username);
}
catch (AuthNotFoundException)
{
throw new LoginFailedException();
}
if (BCrypt.Net.BCrypt.Verify(password, login.HashedPassword))
{
var session = await _sessionRepo.CreateUserSession(login);
return new UserSessionDTO(session);
}
else
{
throw new LoginFailedException();
}
}
public async Task<bool> IsAuthenticated(string? sessionToken)
{
if (sessionToken == null)
{
return false;
}
var session = await _sessionRepo.GetUserSession(sessionToken);
if (session == null)
{
return false;
}
else if (session.ExpiresOn < DateTime.Now)
{
await _sessionRepo.DeleteSession(sessionToken);
return false;
}
else
{
await _sessionRepo.RefreshSessionExpiry(sessionToken);
return true;
}
}
}

View file

@ -0,0 +1,9 @@
using JellyGlass.Models;
namespace JellyGlass.Services;
public interface IAuthService
{
public Task<UserSessionDTO> AuthenticateUser(string username, string password);
public Task<bool> IsAuthenticated(string? sessionToken);
}