mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 01:47:41 +00:00
Latest
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
.DS_Store
|
||||
.env
|
||||
DraftAPI/
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using WinStudentGoalTracker.Api.Configuration;
|
||||
using WinStudentGoalTracker.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
ConfigHelper.Configuration = builder.Configuration;
|
||||
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? "super_secret_key_change_me_in_production_123!";
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "WinStudentGoalTrackerAPI";
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtIssuer,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
RoleClaimType = System.Security.Claims.ClaimTypes.Role
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddScoped<TokenService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
@@ -25,6 +56,10 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseCors();
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
|
||||
<PackageReference Include="MySql.Data" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Port=3306;Database=win_student_goal_tracker;Uid=root;Pwd=change_me;"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "super_secret_key_change_me_in_production_123!",
|
||||
"Issuer": "WinStudentGoalTrackerAPI"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WinStudentGoalTracker.BaseClasses;
|
||||
using WinStudentGoalTracker.DataAccess;
|
||||
using WinStudentGoalTracker.Models;
|
||||
using WinStudentGoalTracker.Services;
|
||||
|
||||
namespace WinStudentGoalTracker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController : BaseController
|
||||
{
|
||||
private readonly UserRepository _userRepo = new();
|
||||
private readonly AuthRepository _authRepo = new();
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public AuthController(TokenService tokenService)
|
||||
{
|
||||
_tokenService = tokenService;
|
||||
}
|
||||
|
||||
[HttpPost("Login")]
|
||||
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ResponseResult<LoginResponse>>> Login([FromBody] LoginDto login)
|
||||
{
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password))
|
||||
{
|
||||
return BadRequest(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Email and password are required."
|
||||
});
|
||||
}
|
||||
|
||||
var user = await _userRepo.GetByEmailAsync(login.Email);
|
||||
if (user == null)
|
||||
{
|
||||
return Ok(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid email or password."
|
||||
});
|
||||
}
|
||||
|
||||
if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow)
|
||||
{
|
||||
return Ok(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Account is temporarily locked. Please try again later."
|
||||
});
|
||||
}
|
||||
|
||||
if (user.PasswordHash == null || user.PasswordSalt == null)
|
||||
{
|
||||
return Ok(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Password not set. Please contact an administrator."
|
||||
});
|
||||
}
|
||||
|
||||
if (!PasswordHasher.VerifyPassword(login.Password, user.PasswordHash, user.PasswordSalt))
|
||||
{
|
||||
return Ok(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid email or password."
|
||||
});
|
||||
}
|
||||
|
||||
// Generate JWT access token
|
||||
var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleName);
|
||||
|
||||
// Generate refresh token secret
|
||||
var secretToken = Guid.NewGuid().ToString();
|
||||
var (refreshTokenHash, refreshTokenSalt) = PasswordHasher.HashPassword(secretToken);
|
||||
|
||||
// Get device info
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress });
|
||||
|
||||
// Store refresh token in database (30 days expiration)
|
||||
var refreshTokenId = await _authRepo.CreateRefreshTokenAsync(
|
||||
user.IdUser,
|
||||
refreshTokenHash,
|
||||
refreshTokenSalt,
|
||||
expiresInSeconds: 2592000, // 30 days
|
||||
deviceInfo: deviceInfo,
|
||||
userAgent: userAgent
|
||||
);
|
||||
|
||||
if (!refreshTokenId.HasValue)
|
||||
{
|
||||
return Ok(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Failed to create refresh token."
|
||||
});
|
||||
}
|
||||
|
||||
// Build full refresh token: {id}.{secret}
|
||||
var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}";
|
||||
|
||||
return Ok(new ResponseResult<LoginResponse>
|
||||
{
|
||||
Success = true,
|
||||
Message = "Login successful.",
|
||||
Data = new LoginResponse
|
||||
{
|
||||
UserId = user.IdUser,
|
||||
Email = user.Email!,
|
||||
Jwt = accessToken,
|
||||
RefreshToken = fullRefreshToken,
|
||||
Role = user.RoleName
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("RefreshToken")]
|
||||
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<ResponseResult<TokenRefreshResponse>>> RefreshToken([FromBody] RefreshTokenDto refreshTokenDto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshTokenDto.RefreshToken))
|
||||
{
|
||||
return BadRequest(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Refresh token is required."
|
||||
});
|
||||
}
|
||||
|
||||
// Split token into ID and secret: {id}.{secret}
|
||||
var dotIndex = refreshTokenDto.RefreshToken.IndexOf('.');
|
||||
if (dotIndex < 1)
|
||||
{
|
||||
return BadRequest(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid refresh token format."
|
||||
});
|
||||
}
|
||||
|
||||
var tokenIdStr = refreshTokenDto.RefreshToken[..dotIndex];
|
||||
var secretToken = refreshTokenDto.RefreshToken[(dotIndex + 1)..];
|
||||
|
||||
if (!int.TryParse(tokenIdStr, out int tokenId))
|
||||
{
|
||||
return BadRequest(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid refresh token ID."
|
||||
});
|
||||
}
|
||||
|
||||
var matchedToken = await _authRepo.GetRefreshTokenByIdAsync(tokenId);
|
||||
if (matchedToken == null)
|
||||
{
|
||||
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid refresh token."
|
||||
});
|
||||
}
|
||||
|
||||
if (!PasswordHasher.VerifyPassword(secretToken, matchedToken.TokenHash, matchedToken.TokenSalt))
|
||||
{
|
||||
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid refresh token."
|
||||
});
|
||||
}
|
||||
|
||||
if (matchedToken.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Refresh token has expired."
|
||||
});
|
||||
}
|
||||
|
||||
if (matchedToken.RevokedAt.HasValue)
|
||||
{
|
||||
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Refresh token has been revoked."
|
||||
});
|
||||
}
|
||||
|
||||
var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser);
|
||||
if (tokenUser == null)
|
||||
{
|
||||
return Unauthorized(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new JWT
|
||||
var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleName);
|
||||
var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken);
|
||||
|
||||
// Generate new refresh token (rotation)
|
||||
var newSecretToken = Guid.NewGuid().ToString();
|
||||
var (newRefreshTokenHash, newRefreshTokenSalt) = PasswordHasher.HashPassword(newSecretToken);
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress });
|
||||
|
||||
var newRefreshTokenId = await _authRepo.ReplaceRefreshTokenAsync(
|
||||
matchedToken.IdRefreshToken,
|
||||
tokenUser.IdUser,
|
||||
newRefreshTokenHash,
|
||||
newRefreshTokenSalt,
|
||||
expiresInSeconds: 2592000,
|
||||
deviceInfo: deviceInfo,
|
||||
userAgent: userAgent
|
||||
);
|
||||
|
||||
if (!newRefreshTokenId.HasValue)
|
||||
{
|
||||
return Ok(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Failed to create new refresh token."
|
||||
});
|
||||
}
|
||||
|
||||
var fullNewRefreshToken = $"{newRefreshTokenId.Value}.{newSecretToken}";
|
||||
|
||||
return Ok(new ResponseResult<TokenRefreshResponse>
|
||||
{
|
||||
Success = true,
|
||||
Message = "Token refreshed successfully.",
|
||||
Data = new TokenRefreshResponse
|
||||
{
|
||||
Jwt = newJwtToken,
|
||||
NewRefreshToken = fullNewRefreshToken,
|
||||
JwtExpiresIn = jwtExpiresIn
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("Logout")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ResponseResult<object>>> Logout([FromBody] RefreshTokenDto logoutDto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logoutDto.RefreshToken))
|
||||
{
|
||||
return BadRequest(new ResponseResult<object>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Refresh token is required."
|
||||
});
|
||||
}
|
||||
|
||||
var (userId, error) = GetUserIdFromClaims();
|
||||
if (error != null) return error;
|
||||
|
||||
var dotIndex = logoutDto.RefreshToken.IndexOf('.');
|
||||
if (dotIndex < 1 || !int.TryParse(logoutDto.RefreshToken[..dotIndex], out int tokenId))
|
||||
{
|
||||
return BadRequest(new ResponseResult<object>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid refresh token format."
|
||||
});
|
||||
}
|
||||
|
||||
var tokenData = await _authRepo.GetRefreshTokenByIdAsync(tokenId);
|
||||
if (tokenData == null || tokenData.IdUser != userId)
|
||||
{
|
||||
return Unauthorized(new ResponseResult<object>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid refresh token."
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenData.RevokedAt.HasValue)
|
||||
{
|
||||
return Ok(new ResponseResult<object>
|
||||
{
|
||||
Success = true,
|
||||
Message = "Already logged out."
|
||||
});
|
||||
}
|
||||
|
||||
await _authRepo.RevokeRefreshTokenAsync(tokenId);
|
||||
|
||||
return Ok(new ResponseResult<object>
|
||||
{
|
||||
Success = true,
|
||||
Message = "Logged out successfully."
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class LoginDto
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class RefreshTokenDto
|
||||
{
|
||||
public string? RefreshToken { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class dbRefreshToken
|
||||
{
|
||||
public int IdRefreshToken { get; set; }
|
||||
public int IdUser { get; set; }
|
||||
public required string TokenHash { get; set; }
|
||||
public required string TokenSalt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime LastUsedAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public string? DeviceInfo { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public int? ReplacedByTokenId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class dbUser
|
||||
{
|
||||
public required int IdUser { get; set; }
|
||||
public int? IdRole { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? PasswordHash { get; set; }
|
||||
public string? PasswordSalt { get; set; }
|
||||
public int FailedLoginAttempts { get; set; }
|
||||
public DateTime? LockedUntil { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public string? RoleName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class AuthRepository
|
||||
{
|
||||
private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString);
|
||||
|
||||
public async Task<int?> CreateRefreshTokenAsync(
|
||||
int userId,
|
||||
string tokenHash,
|
||||
string tokenSalt,
|
||||
int expiresInSeconds,
|
||||
string? deviceInfo,
|
||||
string? userAgent)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<int?>(
|
||||
"sp_RefreshToken_Create",
|
||||
new
|
||||
{
|
||||
p_id_user = userId,
|
||||
p_token_hash = tokenHash,
|
||||
p_token_salt = tokenSalt,
|
||||
p_expires_in_seconds = expiresInSeconds,
|
||||
p_device_info = deviceInfo,
|
||||
p_user_agent = userAgent
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<dbRefreshToken?> GetRefreshTokenByIdAsync(int refreshTokenId)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<dbRefreshToken>(
|
||||
"sp_RefreshToken_GetById",
|
||||
new { p_id_refresh_token = refreshTokenId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeRefreshTokenAsync(int refreshTokenId)
|
||||
{
|
||||
using var db = Connection;
|
||||
var rowsAffected = await db.QuerySingleOrDefaultAsync<int>(
|
||||
"sp_RefreshToken_Revoke",
|
||||
new { p_id_refresh_token = refreshTokenId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
public async Task<int?> ReplaceRefreshTokenAsync(
|
||||
int oldTokenId,
|
||||
int userId,
|
||||
string tokenHash,
|
||||
string tokenSalt,
|
||||
int expiresInSeconds,
|
||||
string? deviceInfo,
|
||||
string? userAgent)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<int?>(
|
||||
"sp_RefreshToken_Replace",
|
||||
new
|
||||
{
|
||||
p_old_token_id = oldTokenId,
|
||||
p_id_user = userId,
|
||||
p_token_hash = tokenHash,
|
||||
p_token_salt = tokenSalt,
|
||||
p_expires_in_seconds = expiresInSeconds,
|
||||
p_device_info = deviceInfo,
|
||||
p_user_agent = userAgent
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class UserRepository
|
||||
{
|
||||
private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString);
|
||||
|
||||
public async Task<dbUser?> GetByEmailAsync(string email)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<dbUser>(
|
||||
"sp_User_GetByEmail",
|
||||
new { p_email = email },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<dbUser?> GetByIdAsync(int idUser)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<dbUser>(
|
||||
"sp_User_GetById",
|
||||
new { p_id_user = idUser },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace WinStudentGoalTracker.Models;
|
||||
|
||||
public class LoginResponse
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public required string Email { get; set; }
|
||||
public required string Jwt { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace WinStudentGoalTracker.Models;
|
||||
|
||||
public class TokenRefreshResponse
|
||||
{
|
||||
public required string Jwt { get; set; }
|
||||
public required string NewRefreshToken { get; set; }
|
||||
public int JwtExpiresIn { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WinStudentGoalTracker.Services;
|
||||
|
||||
public class PasswordHasher
|
||||
{
|
||||
private const int SaltSize = 16; // 128 bit
|
||||
private const int HashSize = 32; // 256 bit
|
||||
private const int Iterations = 100_000;
|
||||
|
||||
public static (string Hash, string Salt) HashPassword(string password)
|
||||
{
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
byte[] saltBytes = new byte[SaltSize];
|
||||
rng.GetBytes(saltBytes);
|
||||
|
||||
using var pbkdf2 = new Rfc2898DeriveBytes(password, saltBytes, Iterations, HashAlgorithmName.SHA256);
|
||||
byte[] hashBytes = pbkdf2.GetBytes(HashSize);
|
||||
|
||||
return (Convert.ToBase64String(hashBytes), Convert.ToBase64String(saltBytes));
|
||||
}
|
||||
|
||||
public static bool VerifyPassword(string password, string storedHash, string storedSalt)
|
||||
{
|
||||
byte[] saltBytes = Convert.FromBase64String(storedSalt);
|
||||
|
||||
using var pbkdf2 = new Rfc2898DeriveBytes(password, saltBytes, Iterations, HashAlgorithmName.SHA256);
|
||||
byte[] hashBytes = pbkdf2.GetBytes(HashSize);
|
||||
|
||||
string incomingHash = Convert.ToBase64String(hashBytes);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(incomingHash),
|
||||
Encoding.UTF8.GetBytes(storedHash));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace WinStudentGoalTracker.Services;
|
||||
|
||||
public class TokenService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes
|
||||
|
||||
public TokenService(IConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public string GenerateToken(int userId, string email, string? roleName)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, email),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim("user_id", userId.ToString())
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(roleName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, roleName));
|
||||
}
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _config["Jwt:Issuer"],
|
||||
audience: null,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddSeconds(_tokenExpiryInSeconds),
|
||||
signingCredentials: creds
|
||||
);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public int GetTokenExpiryInSeconds(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = tokenHandler.ReadJwtToken(token);
|
||||
|
||||
var expiryTime = jwtToken.ValidTo;
|
||||
var currentTime = DateTime.UtcNow;
|
||||
|
||||
var timeUntilExpiry = expiryTime - currentTime;
|
||||
|
||||
return timeUntilExpiry.TotalSeconds > 0 ? (int)timeUntilExpiry.TotalSeconds : 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_Create`(
|
||||
IN p_id_user INT,
|
||||
IN p_token_hash VARCHAR(512),
|
||||
IN p_token_salt VARCHAR(512),
|
||||
IN p_expires_in_seconds INT,
|
||||
IN p_device_info VARCHAR(255),
|
||||
IN p_user_agent VARCHAR(512)
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO refresh_token
|
||||
(
|
||||
id_user,
|
||||
token_hash,
|
||||
token_salt,
|
||||
expires_at,
|
||||
device_info,
|
||||
user_agent
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
p_id_user,
|
||||
p_token_hash,
|
||||
p_token_salt,
|
||||
DATE_ADD(UTC_TIMESTAMP(), INTERVAL p_expires_in_seconds SECOND),
|
||||
p_device_info,
|
||||
p_user_agent
|
||||
);
|
||||
SELECT LAST_INSERT_ID() AS id_refresh_token;
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,21 @@
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_GetById`(IN p_id_refresh_token INT)
|
||||
BEGIN
|
||||
SELECT
|
||||
id_refresh_token,
|
||||
id_user,
|
||||
token_hash,
|
||||
token_salt,
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
device_info,
|
||||
user_agent,
|
||||
replaced_by_token_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM refresh_token
|
||||
WHERE id_refresh_token = p_id_refresh_token
|
||||
LIMIT 1;
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,43 @@
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_Replace`(
|
||||
IN p_old_token_id INT,
|
||||
IN p_id_user INT,
|
||||
IN p_token_hash VARCHAR(512),
|
||||
IN p_token_salt VARCHAR(512),
|
||||
IN p_expires_in_seconds INT,
|
||||
IN p_device_info VARCHAR(255),
|
||||
IN p_user_agent VARCHAR(512)
|
||||
)
|
||||
BEGIN
|
||||
-- Revoke the old token
|
||||
UPDATE refresh_token
|
||||
SET revoked_at = UTC_TIMESTAMP()
|
||||
WHERE id_refresh_token = p_old_token_id
|
||||
AND revoked_at IS NULL;
|
||||
-- Create the new token
|
||||
INSERT INTO refresh_token
|
||||
(
|
||||
id_user,
|
||||
token_hash,
|
||||
token_salt,
|
||||
expires_at,
|
||||
device_info,
|
||||
user_agent
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
p_id_user,
|
||||
p_token_hash,
|
||||
p_token_salt,
|
||||
DATE_ADD(UTC_TIMESTAMP(), INTERVAL p_expires_in_seconds SECOND),
|
||||
p_device_info,
|
||||
p_user_agent
|
||||
);
|
||||
-- Link old token to new one
|
||||
SET @new_id = LAST_INSERT_ID();
|
||||
UPDATE refresh_token
|
||||
SET replaced_by_token_id = @new_id
|
||||
WHERE id_refresh_token = p_old_token_id;
|
||||
SELECT @new_id AS id_refresh_token;
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,10 @@
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_Revoke`(IN p_id_refresh_token INT)
|
||||
BEGIN
|
||||
UPDATE refresh_token
|
||||
SET revoked_at = UTC_TIMESTAMP()
|
||||
WHERE id_refresh_token = p_id_refresh_token
|
||||
AND revoked_at IS NULL;
|
||||
SELECT ROW_COUNT() AS rows_affected;
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
@@ -1,12 +1,8 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_Delete;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_Delete(IN p_id_student INT)
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student INT)
|
||||
BEGIN
|
||||
DELETE FROM student
|
||||
WHERE id_student = p_id_student;
|
||||
|
||||
SELECT ROW_COUNT() AS rows_affected;
|
||||
END$$
|
||||
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_GetAll;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_GetAll()
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetAll`()
|
||||
BEGIN
|
||||
SELECT
|
||||
id_student,
|
||||
@@ -13,6 +11,5 @@ BEGIN
|
||||
created_at
|
||||
FROM student
|
||||
ORDER BY id_student;
|
||||
END$$
|
||||
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_GetById;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_GetById(IN p_id_student INT)
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetById`(IN p_id_student INT)
|
||||
BEGIN
|
||||
SELECT
|
||||
id_student,
|
||||
@@ -14,6 +12,5 @@ BEGIN
|
||||
FROM student
|
||||
WHERE id_student = p_id_student
|
||||
LIMIT 1;
|
||||
END$$
|
||||
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_Insert;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_Insert(
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Insert`(
|
||||
IN p_id_student INT,
|
||||
IN p_id_program INT,
|
||||
IN p_identifier VARCHAR(50),
|
||||
@@ -30,7 +28,6 @@ BEGIN
|
||||
p_expected_grad,
|
||||
UTC_TIMESTAMP()
|
||||
);
|
||||
|
||||
SELECT
|
||||
id_student,
|
||||
id_program,
|
||||
@@ -42,6 +39,5 @@ BEGIN
|
||||
FROM student
|
||||
WHERE id_student = p_id_student
|
||||
LIMIT 1;
|
||||
END$$
|
||||
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_Update;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_Update(
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Update`(
|
||||
IN p_id_student INT,
|
||||
IN p_id_program INT,
|
||||
IN p_identifier VARCHAR(50),
|
||||
@@ -18,8 +16,6 @@ BEGIN
|
||||
enrollment_date = COALESCE(p_enrollment_date, enrollment_date),
|
||||
expected_grad = COALESCE(p_expected_grad, expected_grad)
|
||||
WHERE id_student = p_id_student;
|
||||
|
||||
SELECT ROW_COUNT() AS rows_affected;
|
||||
END$$
|
||||
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetByEmail`(IN p_email VARCHAR(255))
|
||||
BEGIN
|
||||
SELECT
|
||||
u.id_user,
|
||||
u.id_role,
|
||||
u.email,
|
||||
u.name,
|
||||
u.password_hash,
|
||||
u.password_salt,
|
||||
u.failed_login_attempts,
|
||||
u.locked_until,
|
||||
u.created_at,
|
||||
r.name AS role_name
|
||||
FROM `user` u
|
||||
LEFT JOIN role r ON u.id_role = r.id_role
|
||||
WHERE u.email = p_email
|
||||
LIMIT 1;
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,20 @@
|
||||
DELIMITER ;;
|
||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetById`(IN p_id_user INT)
|
||||
BEGIN
|
||||
SELECT
|
||||
u.id_user,
|
||||
u.id_role,
|
||||
u.email,
|
||||
u.name,
|
||||
u.password_hash,
|
||||
u.password_salt,
|
||||
u.failed_login_attempts,
|
||||
u.locked_until,
|
||||
u.created_at,
|
||||
r.name AS role_name
|
||||
FROM `user` u
|
||||
LEFT JOIN role r ON u.id_role = r.id_role
|
||||
WHERE u.id_user = p_id_user
|
||||
LIMIT 1;
|
||||
utf8mb4_0900_ai_ci;;
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `refresh_token` (
|
||||
`id_refresh_token` int NOT NULL AUTO_INCREMENT,
|
||||
`id_user` int NOT NULL,
|
||||
`token_hash` varchar(512) NOT NULL,
|
||||
`token_salt` varchar(512) NOT NULL,
|
||||
`expires_at` timestamp NOT NULL,
|
||||
`last_used_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`revoked_at` timestamp NULL DEFAULT NULL,
|
||||
`device_info` varchar(255) DEFAULT NULL,
|
||||
`user_agent` varchar(512) DEFAULT NULL,
|
||||
`replaced_by_token_id` int DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id_refresh_token`),
|
||||
KEY `idx_refresh_token_user` (`id_user`),
|
||||
KEY `idx_refresh_token_expires` (`expires_at`),
|
||||
KEY `refresh_token_ibfk_2` (`replaced_by_token_id`),
|
||||
CONSTRAINT `refresh_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`),
|
||||
CONSTRAINT `refresh_token_ibfk_2` FOREIGN KEY (`replaced_by_token_id`) REFERENCES `refresh_token` (`id_refresh_token`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE `user` (
|
||||
`email` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(255) DEFAULT NULL,
|
||||
`password_hash` varchar(255) DEFAULT NULL,
|
||||
`password_salt` varchar(255) DEFAULT NULL,
|
||||
`password_updated_at` timestamp NULL DEFAULT NULL,
|
||||
`failed_login_attempts` int DEFAULT '0',
|
||||
`locked_until` timestamp NULL DEFAULT NULL,
|
||||
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/bin/bash
|
||||
# dump-objects.sh
|
||||
|
||||
MYSQL="mysql"
|
||||
BASE_OUTPUT_DIR="$(cd "$(dirname "$0")" && pwd)/Objects"
|
||||
DATABASE="winstudentgoaltracker"
|
||||
|
||||
# Get password once
|
||||
read -s -p "Enter MySQL password: " PASS
|
||||
echo
|
||||
|
||||
# Connection parameters
|
||||
CONN_PARAMS=(-h 10.66.66.1 -P 3309 -u root -p"$PASS")
|
||||
|
||||
# =============================================================================
|
||||
# CONNECTION TEST
|
||||
# =============================================================================
|
||||
echo "Testing connection to MySQL..."
|
||||
if ! $MYSQL "${CONN_PARAMS[@]}" -N -B --raw -e "SELECT 1" &>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: Could not connect to MySQL."
|
||||
echo "Check your password, host (10.66.66.1), port (3309), and that the MySQL server is reachable."
|
||||
exit 1
|
||||
fi
|
||||
echo "Connection OK."
|
||||
|
||||
# Helper function to initialize output directory
|
||||
initialize_output_dir() {
|
||||
local path="$1"
|
||||
if [ -d "$path" ]; then
|
||||
rm -f "$path"/*.sql 2>/dev/null
|
||||
else
|
||||
mkdir -p "$path"
|
||||
fi
|
||||
echo "$path"
|
||||
}
|
||||
|
||||
# Helper function to run mysql and clean output
|
||||
invoke_mysql_query() {
|
||||
local query="$1"
|
||||
$MYSQL "${CONN_PARAMS[@]}" -N -B --raw -e "$query" 2>/dev/null | tr -d '\r' | sed '/^$/d'
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# TABLES (includes indexes; triggers handled separately)
|
||||
# =============================================================================
|
||||
TABLE_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/tables")
|
||||
|
||||
echo ""
|
||||
echo "Fetching table list..."
|
||||
TABLES=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && TABLES+=("$line")
|
||||
done < <(invoke_mysql_query "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '$DATABASE' AND TABLE_TYPE = 'BASE TABLE'")
|
||||
echo "Found ${#TABLES[@]} tables"
|
||||
|
||||
for table in "${TABLES[@]}"; do
|
||||
table=$(echo "$table" | xargs)
|
||||
[ -z "$table" ] && continue
|
||||
echo " Dumping: $table"
|
||||
|
||||
create_stmt=$(invoke_mysql_query "SHOW CREATE TABLE \`$DATABASE\`.\`$table\`" | cut -f2-)
|
||||
if [ -z "$create_stmt" ]; then
|
||||
echo "WARNING: Failed to dump table: $table" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get triggers for this table
|
||||
TRIGGERS=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && TRIGGERS+=("$line")
|
||||
done < <(invoke_mysql_query "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA = '$DATABASE' AND EVENT_OBJECT_TABLE = '$table'")
|
||||
|
||||
trigger_sql=""
|
||||
for trigger in "${TRIGGERS[@]}"; do
|
||||
trigger=$(echo "$trigger" | xargs)
|
||||
[ -z "$trigger" ] && continue
|
||||
|
||||
trigger_def=$(invoke_mysql_query "SHOW CREATE TRIGGER \`$DATABASE\`.\`$trigger\`")
|
||||
if [ -n "$trigger_def" ]; then
|
||||
# SHOW CREATE TRIGGER: TriggerName<TAB>sql_mode<TAB>CreateStatement<TAB>...
|
||||
trigger_create=$(echo "$trigger_def" | cut -f3 | sed 's/\t.*$//')
|
||||
trigger_sql+=$'\n\nDELIMITER ;;\n'"$trigger_create"$';;\nDELIMITER ;'
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s;%s\n' "$create_stmt" "$trigger_sql" > "$TABLE_DIR/$table.sql"
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# VIEWS
|
||||
# =============================================================================
|
||||
VIEW_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/views")
|
||||
|
||||
echo ""
|
||||
echo "Fetching view list..."
|
||||
VIEWS=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && VIEWS+=("$line")
|
||||
done < <(invoke_mysql_query "SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = '$DATABASE'")
|
||||
echo "Found ${#VIEWS[@]} views"
|
||||
|
||||
for view in "${VIEWS[@]}"; do
|
||||
view=$(echo "$view" | xargs)
|
||||
[ -z "$view" ] && continue
|
||||
echo " Dumping: $view"
|
||||
|
||||
create_stmt=$(invoke_mysql_query "SHOW CREATE VIEW \`$DATABASE\`.\`$view\`" | cut -f2 | sed 's/\t[^\t]*\t[^\t]*$//')
|
||||
if [ -z "$create_stmt" ]; then
|
||||
echo "WARNING: Failed to dump view: $view" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
# Basic formatting to break long lines
|
||||
create_stmt=$(echo "$create_stmt" | sed \
|
||||
-e 's/ select /\nselect /g' \
|
||||
-e 's/ from /\nfrom /g' \
|
||||
-e 's/ left join /\nleft join /g' \
|
||||
-e 's/ inner join /\ninner join /g' \
|
||||
-e 's/ join /\njoin /g' \
|
||||
-e 's/ where /\nwhere /g' \
|
||||
-e 's/ and /\n and /g' \
|
||||
-e 's/ or /\n or /g')
|
||||
|
||||
printf '%s;\n' "$create_stmt" > "$VIEW_DIR/$view.sql"
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# FUNCTIONS
|
||||
# =============================================================================
|
||||
FUNCTION_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/functions")
|
||||
|
||||
echo ""
|
||||
echo "Fetching function list..."
|
||||
FUNCTIONS=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && FUNCTIONS+=("$line")
|
||||
done < <(invoke_mysql_query "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '$DATABASE' AND ROUTINE_TYPE = 'FUNCTION'")
|
||||
echo "Found ${#FUNCTIONS[@]} functions"
|
||||
|
||||
for func in "${FUNCTIONS[@]}"; do
|
||||
func=$(echo "$func" | xargs)
|
||||
[ -z "$func" ] && continue
|
||||
echo " Dumping: $func"
|
||||
|
||||
# SHOW CREATE FUNCTION: FuncName<TAB>sql_mode<TAB>CreateStatement<TAB>...
|
||||
create_stmt=$(invoke_mysql_query "SHOW CREATE FUNCTION \`$DATABASE\`.\`$func\`" | cut -f3 | sed 's/END\t.*/END/')
|
||||
if [ -z "$create_stmt" ]; then
|
||||
echo "WARNING: Failed to dump function: $func" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
printf 'DELIMITER ;;\n%s;;\nDELIMITER ;\n' "$create_stmt" > "$FUNCTION_DIR/$func.sql"
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# PROCEDURES
|
||||
# =============================================================================
|
||||
PROCEDURE_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/procedures")
|
||||
|
||||
echo ""
|
||||
echo "Fetching procedure list..."
|
||||
PROCS=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && PROCS+=("$line")
|
||||
done < <(invoke_mysql_query "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '$DATABASE' AND ROUTINE_TYPE = 'PROCEDURE'")
|
||||
echo "Found ${#PROCS[@]} procedures"
|
||||
|
||||
for proc in "${PROCS[@]}"; do
|
||||
proc=$(echo "$proc" | xargs)
|
||||
[ -z "$proc" ] && continue
|
||||
echo " Dumping: $proc"
|
||||
|
||||
# SHOW CREATE PROCEDURE: ProcName<TAB>sql_mode<TAB>CreateStatement<TAB>...
|
||||
create_stmt=$(invoke_mysql_query "SHOW CREATE PROCEDURE \`$DATABASE\`.\`$proc\`" | cut -f3 | sed 's/END\t.*/END/')
|
||||
if [ -z "$create_stmt" ]; then
|
||||
echo "WARNING: Failed to dump procedure: $proc" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
printf 'DELIMITER ;;\n%s;;\nDELIMITER ;\n' "$create_stmt" > "$PROCEDURE_DIR/$proc.sql"
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# SUMMARY
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Done! Schema exported to: $BASE_OUTPUT_DIR"
|
||||
echo " Tables: ${#TABLES[@]}"
|
||||
echo " Views: ${#VIEWS[@]}"
|
||||
echo " Functions: ${#FUNCTIONS[@]}"
|
||||
echo " Procedures: ${#PROCS[@]}"
|
||||
echo "=========================================="
|
||||
Reference in New Issue
Block a user